Python 机器学习示例第四版(五)

原文:annas-archive.org/md5/143aadf706a620d20916160319321b2e

译者:飞龙

协议:CC BY-NC-SA 4.0

第十三章:通过 Transformer 模型推进语言理解和生成

在上一章节中,我们专注于 RNN,并使用它们处理序列学习任务。然而,RNN 容易受到梯度消失问题的影响。在本章节中,我们将探讨 Transformer 神经网络架构,它是为序列到序列任务设计的,特别适用于自然语言处理NLP)。其关键创新是自注意力机制,使得模型能够对输入序列的不同部分进行不同权重的加权,从而比 RNN 更有效地捕捉长程依赖关系。

我们将学习两种利用 Transformer 架构的前沿模型,并深入探讨它们的实际应用,如情感分析和文本生成。预计在前一章节所涉及的任务中,性能将得到提升。

本章节将涵盖以下主题:

  • 理解自注意力

  • 探索 Transformer 架构

  • 使用双向编码器表示从变换器BERT)和 Transformer 改善情感分析

  • 生成文本使用生成预训练变换器GPT

理解自注意力

Transformer 神经网络架构围绕自注意力机制展开。那么,让我们首先通过这一点来开启本章节。自注意力是一种用于机器学习的机制,特别是在 NLP 和计算机视觉领域。它允许模型对输入序列的不同部分的重要性进行加权。

自注意力是一种特定类型的注意力机制。在传统的注意力机制中,重要性权重存在于两个不同输入数据集之间。例如,基于注意力的英法翻译模型可能会聚焦于与当前生成的法语目标单词相关的英文源句子的特定部分(例如,名词、动词)。然而,在自注意力中,重要性加权发生在同一输入序列中的任意两个元素之间。它关注同一序列中的不同部分是如何相互关联的。在英法翻译中,自注意力模型分析每个英语单词与其他所有英语单词的相互作用。通过理解这些关系,模型能够生成更加细致和准确的法语翻译。

在自然语言处理的背景下,传统的循环神经网络按顺序处理输入序列。由于这种顺序处理的方式,RNN 只能很好地处理较短的序列,并捕捉标记之间的较短范围依赖关系。相反,基于自注意力的模型可以同时处理序列中的所有输入标记。对于给定的标记,模型根据它们与给定标记的相关性分配不同的注意力权重,而不考虑它们的位置。因此,它可以捕捉输入中不同标记之间的关系及其长距离依赖关系。基于自注意力的模型在多个序列到序列的任务(如机器翻译、文本摘要和查询回答)中优于 RNN。

让我们讨论自注意力在以下示例中在序列学习任务中的关键作用:

“我读过 Hayden Liu 的Python 机器学习实例,确实是一本好书。”显然,这里的it指的是Python 机器学习实例。当 Transformer 模型处理这个句子时,自注意力将把itPython 机器学习实例关联起来。

我们可以使用基于自注意力的模型来总结文档(例如本章)。与顺序学习的循环神经网络不同,自注意力不受句子顺序的限制,可以识别句子之间的关系(即使是远距离的关系),这确保了摘要反映了整体信息。

给定输入序列中的一个标记,自注意力机制允许模型以不同的注意力水平查看序列中的其他标记。在接下来的部分,我们将详细解释自注意力机制的工作原理。

键(Key)、值(Value)和查询(Query)的表示

自注意力机制应用于序列中的每个标记。其目标是通过捕捉长距离上下文的嵌入向量来表示每个标记。输入标记的嵌入向量由三个向量组成:键(key)、值(value)和查询(query)。对于给定的标记,基于自注意力的模型学习这三个向量以计算嵌入向量。

以下描述了三个向量的含义。为了帮助理解,我们使用一个类比,将模型对序列的理解比作一名侦探调查带有大量线索的犯罪现场。为了解决案件(理解序列的含义),侦探需要弄清楚哪些线索(标记)最重要以及线索如何连接(标记如何相互关联)。

  • 键向量K表示标记的核心信息。它捕捉标记所持有的关键信息,但不包含详细信息。在我们的侦探类比中,一个线索的键向量可能包含见证者看到犯罪的信息,但不包含他们看到的细节。

  • 值向量V包含标记的完整信息。在我们的侦探示例中,一个线索的值向量可以是来自证人的详细陈述。

  • 最后,查询向量 Q 代表了在整个序列上下文中理解给定 token 的重要性。它是一个关于 token 与当前任务相关性的问题。在侦探的调查过程中,他们的关注点会根据当前寻找的目标而改变。可能是使用的武器、受害者、动机,或者其他一些因素。查询向量代表了侦探在调查中的当前关注点。

这三个向量是从输入 token 的嵌入(embedding)中派生出来的。我们再用侦探的例子来讨论它们是如何协同工作的:

  • 首先,我们基于 key 和 query 向量计算注意力得分。基于查询向量 Q,模型分析每个 token,并计算其 key 向量 K 和查询向量 Q 之间的相关性得分。高得分表示 token 对上下文的重要性较高。在侦探的例子中,他们试图弄清楚一个线索与当前调查重点的相关性。例如,当侦探在调查犯罪现场位置时,他们会认为关于建筑的线索 A 与此高度相关。注意,侦探还没有查看线索 A 的详细信息,就像注意力得分是基于 key 向量计算的,而不是基于 value 向量。模型(侦探)将在下一个阶段——嵌入向量生成中使用 value 向量(线索的详细信息)。

  • 接下来,我们使用 value 向量 V 和注意力权重生成 token 的嵌入向量。侦探已经决定了为每个线索分配多少权重(注意力得分),现在他们将结合每个线索的详细信息(value 向量),以创建对犯罪现场的全面理解(嵌入向量)。

    术语“query”,“key”和“value”灵感来自信息检索系统。

    让我们用搜索引擎的例子来说明。给定一个查询,搜索引擎会对每个文档候选项的 key 进行匹配过程,并单独给出排名得分。基于文档的详细信息和排名得分,搜索引擎创建一个搜索结果页面,并检索到与之相关的具体值。

我们已经讨论了 self-attention 机制中的 key、value 和 query 向量,以及它们如何协同工作,使得模型能够从输入序列中捕捉到重要信息。生成的上下文感知嵌入向量封装了序列中 token 之间的关系。希望侦探的犯罪现场类比能帮助你更好地理解。接下来的章节中,我们将看到上下文感知嵌入向量是如何在数学上生成的。

注意力得分计算与嵌入向量生成

我们有一个 token 序列(x[1],x[2],…x[i],…x[n])。这里,n 是序列的长度。

对于给定的令牌,注意力分数的计算从计算序列中每个令牌与该令牌之间的相似度分数开始。相似度分数通过计算当前令牌的查询向量与其他令牌的键向量的点积来得到:

s[ij]=q[i]∙k[j]

在这里,s[ij]是令牌x[i]和x[j]之间的相似度分数,q[i]是x[i]的查询向量,k[j]是x[j]的查询向量。相似度分数衡量了令牌x[j]与当前令牌x[i]的相关性。

你可能会注意到,原始的相似度分数并不直接反映相对相关性(它们可能为负数或大于 1)。回想一下,softmax函数对原始分数进行归一化,将它们转化为概率,使得它们的总和为 1。因此,我们需要使用softmax函数对它们进行归一化。归一化后的分数就是我们所需要的注意力分数:

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_13_001.png

在这里,a[ij]是令牌x[i]和x[j]之间的相似度分数,d 是键向量的维度,而https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_13_002.png的除法是用于缩放。注意力权重(a[i][1],a[i][2],…,a[i][n])表示当前令牌对序列中所有其他令牌的概率分布。

使用归一化后的注意力权重,我们现在可以计算当前令牌的嵌入向量。嵌入向量z[i]是序列中所有令牌的值向量v的加权和,其中每个权重是当前令牌x[i]与相应令牌x[j]之间的注意力分数a[ij]:

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_13_003.png

这个加权和向量被视为当前令牌的上下文感知表示,考虑到它与所有其他令牌的关系。

以序列python machine learning by example为例,我们按以下步骤计算第一个单词python的自注意力嵌入向量:

  1. 我们计算序列中每个单词与单词python之间的点积。它们分别是q[1]∙k[1],q[1]∙k[2],q[1]∙k[3],q[1]∙k[4],和q[1]∙k[5]。在这里,q[1]是第一个单词的查询向量,而k[1]到k[5]分别是五个单词的键向量。

  2. 我们使用除法和softmax激活函数对结果的点积进行归一化,从而找到注意力权重:

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_13_004.png

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_13_005.png

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_13_006.png

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_13_007.png

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_13_008.png

  1. 然后,我们将得到的注意力权重与值向量相乘,v[1],v[2],v[3],v[4],v[5],并将结果加起来:

z[1] = a[11].v[1]+a[12].v[2]+a[13].v[3]+a[14].v[4]+a[15].v[5]

z[1]是序列中第一个单词python的上下文感知嵌入向量。我们对序列中的每个剩余单词重复这一过程,以获得其上下文感知嵌入。

在自注意力机制的训练过程中,键(key)、查询(query)和值(value)向量是通过三个权重矩阵W[k]、W[q]和W[v]使用线性变换创建的:

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_13_009.png

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_13_010.png

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_13_011.png

在这里,W[k]是键变换的权重矩阵,W[q]是查询变换的权重矩阵,W[v]是值变换的权重矩阵。这三个权重矩阵是可学习的参数。在模型训练过程中,它们通常通过基于梯度的优化算法进行更新。

现在,让我们看看如何在 PyTorch 中模拟计算z[1]:

  1. 首先,假设我们有以下整数表示映射,表示输入python machine learning by example中的标记:

    >>> import torch
    >>> sentence = torch.tensor(
            [0, # python
             8, # machine    
             1, # learning
             6, # by
             2] # example
        ) 
    

每个整数对应于词汇中相应标记的索引。

  1. 我们还假设已经有了可以使用的嵌入向量来模拟我们的词汇:

    >>> torch.manual_seed(0)
    >>> embed = torch.nn.Embedding(10, 16)
    >>> sentence_embed = embed(sentence).detach()
    >>> sentence_embed
    tensor([[-1.1258, -1.1524, -0.2506, -0.4339,  0.8487,  0.6920, -0.3160, 
             -2.1152,
              0.3223, -1.2633,  0.3500,  0.3081,  0.1198,  1.2377,  1.1168, 
             -0.2473],
            [-0.8834, -0.4189, -0.8048,  0.5656,  0.6104,  0.4669,  1.9507, 
             -1.0631,
             -0.0773,  0.1164, -0.5940, -1.2439, -0.1021, -1.0335, -0.3126,  
              0.2458],
            [-1.3527, -1.6959,  0.5667,  0.7935,  0.5988, -1.5551, -0.3414,  
              1.8530,
              0.7502, -0.5855, -0.1734,  0.1835,  1.3894,  1.5863,  0.9463, 
             -0.8437],
            [ 1.6459, -1.3602,  0.3446,  0.5199, -2.6133, -1.6965, -0.2282,  
              0.2800,
              0.2469,  0.0769,  0.3380,  0.4544,  0.4569, -0.8654,  0.7813, 
             -0.9268],
             [-0.6136,  0.0316, -0.4927,  0.2484,  0.4397,  0.1124,  0.6408, 
               0.4412,
              -0.1023,  0.7924, -0.2897,  0.0525,  0.5229,  2.3022, -1.4689, 
              -1.5867]]) 
    

在这里,我们的模拟词汇有 10 个标记,嵌入维度是 16。detach()用于创建一个新的张量,该张量与原始张量共享相同的底层数据,但与计算图断开连接。另外,注意你可能会得到不同的嵌入结果,因为某些操作是非确定性的。

  1. 接下来,我们假设有以下三组权重矩阵W[k]、W[q]和W[v]:

    >>> d = sentence_embed.shape[1]
    >>> w_key = torch.rand(d, d)
    >>> w_query = torch.rand(d, d)
    >>> w_value = torch.rand(d, d) 
    

对于矩阵操作,确保向量QK具有相同的维度是至关重要的,以确保它们在一致的特征空间内操作。然而,向量V允许具有不同的维度。在这个示例中,为了简化起见,我们将保持三个向量的维度一致。所以,我们选择 16 作为共同维度。

  1. 现在,我们可以相应地计算python标记的键向量k[1]、查询向量q[1]和值向量v[1]:

    >>> token1_embed = sentence_embed[0]
    >>> key_1 = w_key.matmul(token1_embed)
    >>> query_1 = w_query.matmul(token1_embed)
    >>> value_1 = w_value.matmul(token1_embed) 
    

看看k[1]:

>>> key_1
tensor([-1.1371, -0.5677, -0.9324, -0.3195, -2.8886, -1.2679, -1.1153,  
         0.2904, 0.3825,  0.3179, -0.4977, -3.8230,  0.3699, -0.3932, 
        -1.8788, -3.3556]) 
  1. 我们还可以直接计算键矩阵(由各个标记的键向量组成)如下:

    >>> keys = sentence_embed.matmul(w_key.T)
    >>> keys[0]
    tensor([-1.1371, -0.5677, -0.9324, -0.3195, -2.8886, -1.2679, -1.1153,  
             0.2904, 0.3825,  0.3179, -0.4977, -3.8230,  0.3699, -0.3932, 
            -1.8788, -3.3556]) 
    

类似地,值矩阵可以直接计算如下:

>>> values = sentence_embed.matmul(w_value.T) 
  1. 通过键矩阵和查询向量q[1],我们可以得到注意力权重向量a[1]=(a[11]、a[12]、a[13]、a[14]、a[15]):

    >>> import torch.nn.functional as F
    >>> a1 = F.softmax(query_1.matmul(keys.T) / d ** 0.5, dim=0)
    >>> a1
    tensor([3.2481e-01, 4.2515e-01, 6.8915e-06, 2.5002e-01, 1.5529e-05]) 
    
  2. 最后,我们将得到的注意力权重与值向量相乘,以获得第一个标记的上下文感知嵌入向量:

    >>> z1 = a1.matmul(values)
    >>> z1
    tensor([-0.7136, -1.1795, -0.5726, -0.4959, -0.6838, -1.6460, -0.3782, -1.0066,
            -0.4798, -0.8996, -1.2138, -0.3955, -1.3302, -0.3832, -0.8446, -0.8470]) 
    

这是基于三个玩具权重矩阵W[k]、W[q]和W[v]的python标记的自注意力版本嵌入向量。

实际上,我们通常使用不止一组可训练的权重矩阵W[k]、W[q]和W[v]。这就是为什么自注意力通常被称为多头自注意力。每个注意力头都有自己的键、查询和值变换的可学习参数。使用多个注意力头可以捕捉序列中不同方面的关系。接下来我们来深入了解这一点。

多头注意力

单头注意力机制虽然有效,但可能无法捕捉序列内的多样化关系。多头注意力通过使用多个查询、键和值矩阵(多个“头”)来扩展这一机制。每个头部独立操作,可以并行关注输入序列的不同部分。这使得模型能够同时捕捉多样化的关系。

以之前的示例序列 python machine learning by example 为例,一个注意力头可能会专注于局部依赖,识别出“machine learning”作为一个名词短语;另一个注意力头可能会强调语义关系,推测“examples”是关于“machine learning”的。这就像是多个分析师在检查同一句话。每个分析师专注于不同的方面(例如,一个分析语法,一个分析词序,另一个分析情感)。通过结合他们的见解,你能获得对句子的更全面理解。

最后,来自所有注意力头的输出被拼接并线性变换,生成最终的注意力输出。

在本节中,我们介绍了一个具有可训练参数的自注意力机制。在接下来的章节中,我们将深入探讨围绕自注意力机制构建的 Transformer 架构。

探索 Transformer 的架构

Transformer 架构被提出作为 RNNs 在序列到序列任务中的替代方案。它大量依赖自注意力机制来处理输入和输出序列。

我们将从查看 Transformer 模型的高层次架构开始(该图像基于论文 Attention Is All You Need,由 Vaswani 等人提供):

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_13_01.png

图 13.1:Transformer 架构

如你所见,Transformer 由两部分组成:编码器(左侧的大矩形)和解码器(右侧的大矩形)。编码器对输入序列进行加密。它具有多头注意力层和常规的前馈层。另一方面,解码器生成输出序列。它具有一个掩蔽的多头注意力层(我们稍后会详细讨论),以及一个多头注意力层和常规的前馈层。

在步骤 t 时,Transformer 模型接收输入步骤 x[1], x[2], …, x[t] 和输出步骤 y[1], y[2], …, y[t][−1],然后预测 y[t]。这与多对多的 RNN 模型没有不同。在下一节中,我们将探索 Transformer 中使其与 RNN 区别开来的重要元素,包括编码器-解码器结构、位置编码和层归一化。

编码器-解码器结构

编码器-解码器结构是 Transformer 架构中的关键元素。它利用模型处理序列到序列任务的能力。以下是编码器组件和解码器组件的详细拆解,并通过类比帮助你理解。

编码器组件处理输入序列并创建上下文表示。通常,编码器组件是由多个编码器堆叠而成。每个编码器由一个自注意力层和一个前馈神经网络组成。我们已经介绍了自注意力允许每个标记关注序列中的其他标记。与像 RNN 这样的顺序模型不同,Transformer 能够捕捉标记之间的关系(即使是远距离的关系),而前馈神经网络为模型的学习能力增加了非线性。我们在深度神经网络中也见过这种情况。

假设你想在餐厅点餐。Transformer 中的编码器工作原理类似于你阅读菜单并生成自己的理解。编码器接收菜单(输入的单词序列),它通过自注意力分析这些单词,就像你阅读每道菜肴的描述和其配料(单词之间的关系)。在编码器(可能是多个编码器堆叠在一起)消化了菜单的信息后,它创建一个简化的表示,编码的上下文,捕捉菜单的精髓。这个编码器的输出就像你对菜单的理解。

对于由多个相同编码器块组成的编码器组件,每个编码器的输出作为后续块的输入,构成了堆叠结构。这种堆叠方法提供了一种强大的方式来捕捉更复杂的关系,并创建对输入序列更丰富的理解。在 RNN 中我们看到了类似的方法。如果你有疑问,原始设计中在Attention Is All You Need中使用了六个编码器块。编码器的数量并非神奇数字,而是需要通过实验来确定。

解码器组件利用编码器提供的上下文表示生成输出序列。与编码器类似,解码器组件也由多个堆叠的解码器块组成。同样,每个解码器块包含一个自注意力层和一个前馈神经网络。然而,解码器中的自注意力与编码器中的自注意力略有不同。它关注的是输出序列,但只考虑它已经构建的上下文。这意味着,对于给定的标记,解码器自注意力仅考虑之前处理过的标记和当前标记之间的关系。回想一下,编码器中的自注意力可以一次性关注整个输入序列。因此,我们称解码器中的自注意力为掩蔽自注意力

除了一个掩蔽自注意力层和一个前馈神经网络,解码器块还具有一个额外的注意力层,称为编码器-解码器注意力。它关注编码器提供的上下文表示,以确保生成的输出序列与编码的上下文相关。

回到餐厅点餐的类比。现在,你(解码器)想要下单(生成输出序列)。解码器使用编码器-解码器自注意力来考虑编码后的上下文(你对菜单的理解)。编码器-解码器自注意力确保输出的生成是基于你对菜单的理解,而不是其他人的。解码器使用掩蔽自注意力来逐字生成输出(你点的菜肴)。掩蔽自注意力确保你不会“偷看”还未“点餐”(生成)的未来菜肴(单词)。随着每个生成的词(你点的菜肴),解码器可以基于编码后的上下文来调整对所需输出序列(餐点)的理解。

类似于编码器,在堆叠的解码器组件中,每个解码器块的输出作为下一个解码器块的输入。由于编码器-解码器自注意力机制,解码器块的数量通常与编码器块的数量相同。

在训练过程中,模型会同时接收输入序列和目标输出序列。它通过最小化预测结果与实际目标序列之间的差异来学习生成目标输出序列。

在本节中,我们深入探讨了 Transformer 的编码器-解码器结构。编码器堆栈提取输入序列的上下文表示。解码器则逐个生成输出序列的标记,关注编码后的上下文以及之前生成的标记。

位置编码

虽然强大,自注意力机制在仅基于内容区分元素的重要性方面存在困难。例如,给定句子“白色狐狸跳过棕色狗”,自注意力可能会因为foxdog在语法角色(名词)上相似,而给它们分配相似的重要性。为了解决这个局限性,引入了位置编码来将位置信息注入自注意力机制。

位置编码是一个固定大小的向量,包含序列中标记的位置信息。它通常是基于数学函数计算的。一种常见的方法是使用正弦和余弦函数的组合,如下所示:

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_13_012.png

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_13_013.png

在这里,i 是维度索引,pos 是标记的位置,d 是嵌入向量的维度。PE(pos, 2i) 表示位置 pos 的位置编码中第 2i 维的值;PE(pos, 2i+1) 表示位置 pos 的位置编码中第 2i+1 维的值。https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_13_014.png 为不同维度引入了不同的频率。

让我们尝试使用四维向量对简单句子“Python machine learning”中的单词位置进行编码。对于第一个单词“Python”,我们有以下内容:

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_13_015.png

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_13_016.png

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_13_017.png

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_13_018.png

对于第二个词“machine”,我们得到以下结果:

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_13_019.png

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_13_020.png

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_13_021.png

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_13_022.png

所以,我们为“Python”提供了位置编码 [0, 1, 0, 1],为“machine”提供了位置编码 [0.8, 0.5, 0, 1]。我们将留给你一个练习,自己完成第三个词的位置编码。

在为每个位置计算位置编码向量之后,它们会被加到相应标记的嵌入表示中。如图 13.1 所示,位置编码在输入嵌入进入编码器组件之前被加到输入嵌入中。同样,位置编码会在输出嵌入进入解码器组件之前加到输出嵌入中。现在,当自注意力学习标记之间的关系时,它会同时考虑内容(标记本身)和它们的位置相关信息。

位置编码是 Transformer 中自注意力的重要补充。由于 Transformer 本身不像 RNN 那样固有地理解标记的顺序,因此额外的位置信息使得它能够有效地捕捉序列依赖关系。在下一部分,我们将研究 Transformer 中另一个关键组件——层归一化。

层归一化

一个 Transformer 由多个层(由多头自注意力组成的编码器和解码器块)构成,它可能会遇到梯度爆炸或梯度消失的问题。这使得网络在训练过程中很难有效学习。层归一化通过对每一层的输出进行归一化,帮助解决了这一问题。

归一化是独立应用于每一层的激活,包括自注意力层和前馈网络。这意味着每一层的输出都会保持在一个特定的范围内,以防止梯度过大或过小。因此,层归一化能够稳定训练过程,并提高 Transformer 的学习效率。

我们已经深入理解了 Transformer 架构及其组成部分,包括编码器、解码器、多头自注意力、掩蔽自注意力、位置编码和层归一化。接下来,我们将学习基于 Transformer 架构的模型 BERT 和 GPT,并探讨它们的应用,包括情感分析和文本生成。

使用 BERT 和 Transformer 改进情感分析

BERT (arxiv.org/abs/1810.04805v2) 是一个基于 Transformer 架构的模型。近年来,它在各种语言理解任务中取得了显著的成功。

正如其名称所示,双向性是 BERT 与早期 Transformer 模型之间的一个显著区别。传统模型通常以单向方式处理序列,而 BERT 则以双向方式处理整个上下文。这种双向上下文理解使得模型在捕捉序列中的细微关系时更加有效。

BERT 基本上是一个训练过的 Transformer 编码器堆叠。它在大量未标记的文本数据上进行自监督预训练。在预训练过程中,BERT 专注于理解文本的上下文意义。预训练完成后,BERT 可以针对特定的下游任务进行微调。

让我们先来讨论一下预训练工作。

预训练 BERT

BERT 的预训练目标是捕捉丰富的上下文化单词表示。它通过在大量语料上进行自监督训练,处理未标记的文本数据。预训练过程包括两个主要任务:掩蔽语言模型MLM)任务和下一句预测NSP)任务。以下是具体分解。

MLM

在 MLM 任务中,句子中的随机单词被替换为特殊标记[MASK]。BERT 将修改后的句子作为输入,并尝试基于周围的单词预测原始被掩蔽的单词。在这个填空游戏中,BERT 被训练去理解单词的意义和上下文。

值得注意的是,在 MLM 任务中,模型使用双向上下文进行训练——即每个掩蔽单词的左右上下文。这提高了掩蔽单词预测的准确性。因此,通过处理大量的训练样本,BERT 在理解单词意义和捕捉不同上下文中的关系方面变得更为出色。

NSP

NSP 任务帮助模型理解句子之间的关系和话语级别的信息。在训练过程中,句子对会从训练语料库中随机抽取。对于每一对句子,第二个句子有 50%的概率跟随第一个句子出现,也有 50%的概率不跟随第一个句子。将两个句子连接起来形成一个训练样本。特殊的[CLS][SEP]标记用于格式化训练样本:

  • [CLS](分类)标记被添加到训练样本的开始位置。对应于[CLS]标记的输出用于表示整个输入序列,用于分类任务。

  • [SEP](分隔符)标记用于分隔两个连接的输入句子。

BERT 的预训练过程如下图所示(请注意,图中的“C”是“Class”的缩写):

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_13_02.png

图 13.2:BERT 预训练

如你在预训练图中所见,模型在连接的句子上进行训练,以预测第二个句子是否跟随第一个句子。它会接收正确的标签(是否是下一个句子)作为反馈,并调整其参数以提高预测准确性。通过这种句子配对任务,BERT 学会了理解句子之间的关系以及思想在文本中的连贯流动。

有趣的是,图表显示 BERT 的预训练同时结合了 MLM 和 NSP 任务。模型在句子中预测被遮盖的标记,同时还判断句子对是否是连续的。

虽然 MLM 任务侧重于词汇级的上下文化,NSP 任务有助于 BERT 更广泛地理解句子之间的关系。预训练后,BERT 可以微调以适应特定的下游 NLP 任务。接下来我们来看如何进行微调。

BERT 的微调

BERT 通常会针对目标任务(如情感分析或命名实体识别)进行微调。任务特定的层被添加到预训练模型之上,并在与任务相关的标注数据上训练模型。以下是微调的逐步过程:

  1. 我们首先收集特定于下游任务的标注数据。

  2. 接下来,我们需要选择一个预训练的 BERT 模型。有多种选择,例如 BERT-base 和 BERT-large。你应该选择适合下游任务和计算能力的模型。

  3. 根据所选的 BERT 模型,我们使用相应的分词器来对输入进行分词。Hugging Face 的 tokenizers 包中提供了多种分词器(huggingface.co/docs/tokenizers/python/latest/index.html)。但你应该使用与模型匹配的分词器。

  4. 有趣的部分来了——架构修改。我们可以在预训练的 BERT 模型上添加任务特定的层。例如,你可以为情感分析添加一个带有 sigmoid 激活的单神经元。

  5. 我们接着定义任务特定的损失函数和目标,以进行训练。再次以情感分析为例,你可以使用二元交叉熵损失函数。

  6. 最后,我们在标注数据上训练修改后的 BERT 模型。

  7. 我们通常会执行超参数调优,以找到最佳的模型配置,包括学习率、批次大小和正则化。

微调 BERT 利用了预训练期间获得的知识,并将其适应于特定任务。通过这种迁移学习策略,BERT 无需从头开始,因此能够更快学习新知识,并且需要更少的数据。在接下来的部分,我们将使用 BERT 提升电影评论的情感预测。

对预训练 BERT 模型进行情感分析的微调

第十二章《使用循环神经网络进行序列预测》中,我们开发了一个 LSTM 模型用于电影评论情感预测。在接下来的步骤中,我们将对同一任务微调预训练的 BERT 模型:

  1. 首先,我们从 PyTorch 内置数据集中读取 IMDb 评论数据:

    >>> from torchtext.datasets import IMDB
    >>> train_dataset = list(IMDB(split='train'))
    >>> test_dataset = list(IMDB(split='test'))
    >>> print(len(train_dataset), len(test_dataset))
    25000 25000 
    

我们加载了 25,000 个训练样本和 25,000 个测试样本。

  1. 然后,我们将原始数据分为文本和标签数据,因为我们需要对文本数据进行分词:

    >>> train_texts = [train_sample[1] for train_sample in train_dataset]
    >>> train_labels = [train_sample[0] for train_sample in train_dataset]
    >>> test_texts = [test_sample[1] for test_sample in test_dataset]
    >>> test_labels = [test_sample[0] for test_sample in test_dataset] 
    

我们已经完成了数据准备,并进入分词步骤。

  1. 现在,我们需要选择一个合适的预训练模型和相应的分词器。考虑到我们有限的计算资源,我们选择了distilbert-base-uncased模型。可以将其视为 BERT 的一个更小(“蒸馏”)、更快的版本。它保留了 BERT 的大部分功能,但参数更少。“uncased”部分意味着该模型是基于小写文本训练的。为了确保一切顺利,我们将使用与模型匹配的distilbert-base-uncased分词器。

  2. 如果你还没有安装 Hugging Face 的 transformers 包,可以通过命令行按如下方式安装:

    pip install transformers==4.32.1 
    

或者,使用以下方式:

conda install -c huggingface transformers=4.32.1 

截至目前,我们使用的是4.32.1版本。可以自由地使用此版本进行复制,因为 transformers 包会经常更新。

  1. Hugging Face 的预训练模型可以加载并在本地下载和缓存。我们按如下方式加载distilbert-base-uncased分词器:

    >>> import transformers
    >>> from transformers import DistilBertTokenizerFast
    >>> tokenizer = DistilBertTokenizerFast.from_pretrained('distilbert-base-uncased') 
    

我们也可以提前手动下载一个transformers模型,并从指定的本地路径读取。要获取distilbert-base-uncased预训练模型和分词器,可以在huggingface.co/models搜索distilbert-base-uncased,并点击distilbert-base-uncased的链接。模型和分词器文件可以在Files选项卡中找到,或者直接在huggingface.co/distilbert-base-uncased/tree/main查看。下载所有文件除了flax_model.msgpackmodel.safetensorsrust_model.ottf_model.h5(因为我们只需要 PyTorch 模型),并将它们放在名为distilbert-base-uncased的文件夹中。最后,我们将能够按如下方式从distilbert-base-uncased路径加载分词器:

>>> tokenizer = DistilBertTokenizerFast.from_pretrained(
                                             'distilbert-base-uncased',
                                             local_files_only=True) 
  1. 现在,我们对训练集和测试集中的输入文本进行分词:

    >>> train_encodings = tokenizer(train_texts, truncation=True,
                                    padding=True)
    >>> test_encodings = tokenizer(test_texts, truncation=True, padding=True) 
    

看一下第一个训练样本的编码结果:

>>> train_encodings[0]
Encoding(num_tokens=512, attributes=[ids, type_ids, tokens, offsets, attention_mask, special_tokens_mask, overflowing]) 

在这里,生成的编码对象最多只能容纳 512 个标记。如果原始文本更长,它将被截断。属性是与编码过程相关的信息列表,包括ids(表示标记 ID)和attention_mask(指定哪些标记应被关注,哪些应被忽略)。

  1. 接下来,我们将所有数据字段,包括标签,封装在一个Dataset类中:

    >>> import torch
    >>> class IMDbDataset(torch.utils.data.Dataset):
        def __init__(self, encodings, labels):
            self.encodings = encodings
            self.labels = labels
        def __getitem__(self, idx):
            item = {key: torch.tensor(val[idx])
                for key, val in self.encodings.items()}
            item['labels'] = torch.tensor([0., 1.] 
                                   if self.labels[idx] == 2
                                   else [1., 0.])
            return item
        def __len__(self):
            return len(self.labels) 
    

请注意,我们将正标签(表示为“2”)和负标签(表示为“1”)转换为格式[0, 1][1, 0]。我们做出这个调整是为了与 DistilBERT 要求的标签格式对齐。

  1. 然后,我们为训练数据和测试数据生成自定义的Dataset对象:

    >>> train_encoded_dataset = IMDbDataset(train_encodings, train_labels)
    >>> test_encoded_dataset = IMDbDataset(test_encodings, test_labels) 
    
  2. 根据生成的数据集,我们创建批量数据加载器,并为模型微调和评估做好准备:

    >>> batch_size = 32
    >>> train_dl = torch.utils.data.DataLoader(train_encoded_dataset,
                                               batch_size=batch_size,
                                               shuffle=True)       
    >>> test_dl = torch.utils.data.DataLoader(test_encoded_dataset,
                                              batch_size=batch_size,
                                              shuffle=False) 
    
  3. 完成数据准备和分词后,下一步是加载预训练模型并使用我们刚刚准备好的数据集进行微调。加载预训练模型的代码如下:

    >>> from transformers import DistilBertForSequenceClassification
    >>> device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
    >>> model = DistilBertForSequenceClassification.from_pretrained(
                                                   'distilbert-base-uncased',
                                                  local_files_only=True)
    >>> model.to(device) 
    

我们加载了前面提到的预训练distilbert-base-uncased模型。同时,我们确保模型被放置在指定的计算设备上(如果有 GPU,强烈推荐使用 GPU)进行训练和推理。

transformers包提供了多种预训练模型。你可以在huggingface.co/docs/transformers/index#supported-models-and-frameworks上浏览它们。

  1. 我们设置了相应的Adam优化器,学习率为0.00005,如下所示:

    >>> optimizer = torch.optim.Adam(model.parameters(), lr=5e-5) 
    
  2. 现在,我们定义了一个训练函数,负责训练(微调)模型一次:

    >>> def train(model, dataloader, optimizer):
        model.train()
        total_loss = 0
        for batch in dataloader:
            optimizer.zero_grad()
            input_ids = batch['input_ids'].to(device)
            attention_mask = batch['attention_mask'].to(device)
            labels = batch['labels'].to(device)
            outputs = model(input_ids, attention_mask=attention_mask, 
                            labels=labels)
            loss = outputs['loss']
    
            optimizer.zero_grad()
            loss.backward()
            optimizer.step()
            total_loss += loss.item()*len(batch)
        return total_loss/len(dataloader.dataset) 
    

这与我们使用的训练函数类似,只不过 BERT 需要同时输入 token ID 和attention_mask

  1. 同样,我们定义了评估函数,用于评估模型的准确率:

    >>> def evaluate(model, dataloader):
        model.eval()
        total_acc = 0
        with torch.no_grad():
            for batch in dataloader:
                input_ids = batch['input_ids'].to(device)
                attention_mask = batch['attention_mask'].to(device)
                labels = batch['labels'].to(device)
                outputs = model(input_ids, attention_mask=attention_mask)
                logits = outputs['logits']
                pred = torch.argmax(logits, 1)
                 total_acc += (pred == torch.argmax(labels, 1)).float().sum().item()
        return  total_acc/len(dataloader.dataset) 
    
  2. 然后,我们训练模型进行一次迭代,并在结束时显示训练损失和准确率:

    >>> torch.manual_seed(0)
    >>> num_epochs = 1
    >>> for epoch in range(num_epochs):
    >>>     train_loss = train(model, train_dl, optimizer)
    >>>     train_acc = evaluate(model, train_dl)
    >>>     print(f'Epoch {epoch+1} - loss: {train_loss:.4f} -
                    accuracy: {train_acc:.4f}')
    Epoch 1 - loss: 0.0242 - accuracy: 0.9642 
    

训练过程需要一些时间,训练准确率为 96%。如果资源和时间允许,你可以进行更多的迭代训练。

  1. 最后,我们在测试集上评估模型性能:

    >>> test_acc = evaluate(model, test_dl)
    >>> print(f'Accuracy on test set: {100 * test_acc:.2f} %')
    Accuracy on test set: 92.75 % 
    

我们通过对预训练的 DistilBERT 模型进行一次 epoch 的微调,获得了 93%的测试准确率。这相比于在第十二章中使用 LSTM 得到的 86%测试准确率,取得了显著提升。

最佳实践

如果你正在处理大型模型或数据集并使用 GPU,建议监控 GPU 内存使用情况,以避免 GPU 内存不足。

在 PyTorch 中,你可以使用torch.cuda.mem_get_info()来检查 GPU 内存使用情况。它会告诉你关于可用和已分配 GPU 内存的信息。另一个小技巧是torch.cuda.empty_cache(),它会尝试释放 GPU 内存分配器未使用的缓存内存回系统。最后,如果你不再使用某个模型,可以运行del model来释放它占用的内存。

使用 Trainer API 训练 Transformer 模型

Hugging Face 中包含的 Trainer API 是训练基于 Transformer 模型的快捷方式。它让你能够轻松地在自己的任务上微调这些预训练模型,提供了一个简单的高级接口。无需再像之前那样与大量的训练代码作斗争。

在接下来的步骤中,我们将更方便地使用Trainer API 对 BERT 模型进行微调:

  1. 再次加载预训练模型并创建相应的优化器:

    >>> model = DistilBertForSequenceClassification.from_pretrained(
                                                    'distilbert-base-uncased',
                                                    local_files_only=True)
    >>> model.to(device)
    >>> optim = torch.optim.Adam(model.parameters(), lr=5e-5) 
    
  2. 要执行Trainer脚本,需要安装 accelerate 包。使用以下命令来安装accelerate

    conda install -c conda-forge accelerate 
    

或者

pip install accelerate 
  1. 接下来,我们准备必要的配置并初始化一个Trainer对象来训练模型:

    >>> from transformers import Trainer, TrainingArguments
    >>> training_args = TrainingArguments(
            output_dir='./results',
            num_train_epochs=1,   
            per_device_train_batch_size=32,
            logging_dir='./logs',
            logging_steps=50,
        )
    >>> trainer = Trainer(
        model=model,
        args=training_args,
        train_dataset=train_encoded_dataset,
        optimizers=(optim, None)
    ) 
    

这里,TrainingArguments配置定义了训练的轮数、训练批次大小以及每次记录训练指标的步骤数。我们还告诉 Trainer 使用哪个模型,训练的数据是什么,以及使用什么优化器——第二个元素(None)表示这次没有学习率调度器。

  1. 你可能会注意到,前一步Trainer初始化时并没有涉及评估指标和测试数据集。我们来添加这些并重写初始化:

    >>> from datasets import load_metric
    >>> import numpy as np
    >>> metric = load_metric("accuracy")
    >>> def compute_metrics(eval_pred):
            logits, labels = eval_pred
            pred = np.argmax(logits, axis=-1)
            return metric.compute(predictions=pred, 
                                  references=np.argmax(labels, 1))
    >>> trainer = Trainer(
            model=model,
            compute_metrics=compute_metrics,
            args=training_args,
            train_dataset=train_encoded_dataset,
            eval_dataset=test_encoded_dataset,
            optimizers=(optim, None)
        ) 
    

compute_metrics函数根据预测标签和真实标签计算准确率。Trainer将使用这个指标来衡量模型在指定测试集(test_encoded_dataset)上的表现。

  1. 现在,我们只用一行代码来训练模型:

    >>> trainer.train()
    Step     Training Loss
    50       0.452500
    100      0.321200
    150      0.325800
    200      0.258700
    250      0.244300
    300      0.239700
    350      0.256700
    400      0.234100
    450      0.214500
    500      0.240600
    550      0.209900
    600      0.228900
    650      0.187800
    700      0.194800
    750      0.189500
    TrainOutput(global_step=782, training_loss=0.25071206544061453, metrics={'train_runtime': 374.6696, 'train_samples_per_second': 66.725, 'train_steps_per_second': 2.087, 'total_flos': 3311684966400000.0, 'train_loss': 0.25071206544061453, 'epoch': 1.0}) 
    

模型仅根据我们的指定训练了一个 epoch,且每 50 步就会显示训练损失。

  1. 只需再写一行代码,我们就可以在测试数据集上评估训练好的模型:

    >>> print(trainer.evaluate())
    {'eval_loss': 0.18415148556232452, 'eval_accuracy': 0.929, 'eval_runtime': 122.457, 'eval_samples_per_second': 204.153, 'eval_steps_per_second': 25.519, 'epoch': 1.0} 
    

我们利用 Trainer API 得到了相同的 93%的测试准确率,并且所需的代码明显更少。

最佳实践

以下是微调 BERT 的一些最佳实践:

  • 数据至关重要:你应该优先使用高质量且标注良好的数据。

  • 从小做起:你可以从较小的预训练模型开始,比如 BERT-base 或 DistilBERT。它们相比于较大的模型(如 BERT-large)对计算资源的需求更低。

  • 自动化超参数调优:你可以利用自动化超参数调优库(例如 Hyperopt、Optuna)来搜索最佳超参数。这可以节省你的时间,并让计算机进行繁重的计算工作。

  • 实施提前停止:在训练过程中,你应该监控验证损失。如果验证损失在一段时间后不再变得更好,那么就应该停止训练。这种提前停止策略可以防止不必要的训练迭代。记住,微调 BERT 可能需要一些时间和资源。

在这一部分,我们讨论了基于 Transformer 编码器的 BERT 模型,并利用它来增强情感分析。接下来的部分,我们将探讨另一种基于 Transformer 的模型,GPT

使用 GPT 生成文本

BERT 和 GPT 都是基于 Transformer 架构的最先进的自然语言处理模型。然而,它们在架构、训练目标和使用场景上有所不同。我们将首先了解更多关于 GPT 的内容,然后用微调后的 GPT 模型生成我们自己的战争与和平

GPT 的预训练和自回归生成

GPT(通过生成预训练改善语言理解,作者:Alec Radford 等,2018)是一个仅解码器的 Transformer 架构,而 BERT 是仅编码器。这意味着 GPT 在解码器中使用了屏蔽自注意力,并且强调预测序列中的下一个标记。

可以将 BERT 看作是一位超级侦探。它接收到一个句子,其中一些单词被隐藏(屏蔽),并且必须根据线索(周围的单词)在两个方向上推测出这些单词是什么,就像从各个角度观察犯罪现场。而 GPT 则更像是一位创造性的故事讲述者。它使用自回归语言模型目标进行预训练。它从一个起始词开始,逐个添加单词,利用之前的单词作为灵感,类似于我们写故事的方式。这个过程会一直重复,直到达到所需的序列长度。

“自回归”一词意味着它以顺序的方式逐个标记地生成文本。

BERT 和 GPT 都在大规模数据集上进行了预训练。然而,由于它们的训练方法不同,它们在微调时具有不同的优势和应用场景。BERT 擅长理解单词和句子之间的关系。它可以用于情感分析和文本分类等任务。另一方面,GPT 更擅长生成语法正确且流畅的文本。这使得它在文本生成、机器翻译和摘要等任务中表现更好。

在下一部分,我们将像在第十二章《使用循环神经网络进行序列预测》中那样写我们自己的*《战争与和平》*版本,但这次我们将通过微调 GPT 模型来实现。

使用 GPT 写你自己的*《战争与和平》*版本

为了说明,我们将使用 GPT-2,这是一个比 GPT-1 更强大但比 GPT-3 更小的开源模型,来生成我们自己的*《战争与和平》*版本,以下是具体步骤:

  1. 在我们开始之前,让我们快速看看如何使用 GPT-2 模型生成文本:

    >>> from transformers import pipeline, set_seed
    >>> generator = pipeline('text-generation', model='gpt2')
    >>> set_seed(0)
    >>> generator("I love machine learning",
                  max_length=20,
                  num_return_sequences=3)
    [{'generated_text': 'I love machine learning, so you should use machine learning as your tool for data production.\n\n'},
    {'generated_text': 'I love machine learning. I love learning and I love algorithms. I love learning to control systems.'},
    {'generated_text': 'I love machine learning, but it would be pretty difficult for it to keep up with the demands and'}] 
    
I love machine learning,and it produces three alternative sequences with a maximum length of 20 tokens each.
  1. 为了生成我们自己的*《战争与和平》版本,我们需要基于原始的《战争与和平》*文本微调 GPT-2 模型。我们首先需要加载基于 GPT-2 的标记器:

    >>> from transformers import TextDataset, GPT2Tokenizer
    >>> tokenizer = GPT2Tokenizer.from_pretrained('gpt2', local_files_only=True) 
    
  2. 接下来,我们为经过标记化的*《战争与和平》*文本创建一个Dataset对象:

    >>> text_dataset = TextDataset(tokenizer=tokenizer,
                                   file_path='warpeace_input.txt',
                                   block_size=128) 
    

我们通过标记化'warpeace_input.txt'文件中的文本(我们在第十二章中使用的文件)准备了一个数据集(text_dataset)。我们将tokenizer参数设置为之前创建的 GPT-2 标记器,并将block_size设置为128,指定每个标记序列的最大长度。

>>> len(text_dataset)
>>> 6176 

我们基于原始文本生成了6176个训练样本。

  1. 回顾一下第十二章,我们必须手动创建训练数据。幸运的是,这次我们使用了 Hugging Face 的DataCollatorForLanguageModeling类来自动生成输入序列和输出标记。这个数据聚合器是专为语言建模任务设计的,我们试图预测被屏蔽的标记。让我们看看如何创建数据聚合器,具体如下:

    >>> from transformers import DataCollatorForLanguageModeling
    >>> data_collator = DataCollatorForLanguageModeling(tokenizer=tokenizer,
                                                        mlm=False) 
    

数据整理器帮助组织和批处理输入数据,以训练语言模型。我们将mlm参数设置为 False。如果设置为True,它将启用掩码语言建模,其中 tokens 会被随机掩盖,模型需要预测这些被掩盖的部分,就像我们在 BERT 中做的那样。在我们这里,它被关闭,意味着模型是使用自回归方法进行训练的。这种方法非常适合文本生成任务,因为模型学习逐字生成新文本。

  1. 完成分词和数据整理后,下一步是加载预训练的 GPT-2 模型并创建相应的优化器:

    >>> import torch
    >>> from transformers import GPT2LMHeadModel
    >>> model = GPT2LMHeadModel.from_pretrained('gpt2')
    >>> model.to(device)
    >>> optim = torch.optim.Adam(model.parameters(), lr=5e-5) 
    

我们使用GPT2LMHeadModel进行文本生成。它预测序列中下一个 token 的概率分布。

  1. 接下来,我们准备必要的配置并初始化一个Trainer对象来训练模型:

    >>> from transformers import Trainer, TrainingArguments
    >>> training_args = TrainingArguments(
            output_dir='./gpt_results',
            num_train_epochs=20,   
            per_device_train_batch_size=16,
            logging_dir='./gpt_logs',
            save_total_limit=1,
            logging_steps=500,
        )
    >>> trainer = Trainer(
        model=model,
        args=training_args,
        data_collator=data_collator,
        train_dataset=train_dataset,
        optimizers=(optim, None)
    ) 
    

模型将在text_dataset数据集上进行训练,该数据集分成16个样本批次,进行20轮训练。我们还设置了保存的检查点的总数限制。在这种情况下,只有最新的检查点会被保存。这是为了减少空间消耗。在trainer中,我们提供了DataCollatorForLanguageModeling实例来组织数据进行训练。

  1. 现在,我们准备好训练模型了:

    >>> trainer.train()
    Step    Training Loss
    500        3.414100
    1000       3.149500
    1500       3.007500
    2000       2.882600
    2500       2.779100
    3000       2.699200
    3500       2.621700
    4000       2.548800
    4500       2.495400
    5000       2.447600
    5500       2.401400
    6000       2.367600
    6500       2.335500
    7000       2.315100
    7500       2.300400
    TrainOutput(global_step=7720, training_loss=2.640813370936893, metrics={'train_runtime': 1408.7655, 'train_samples_per_second': 87.68, 'train_steps_per_second': 5.48, 'total_flos': 8068697948160000.0, 'train_loss': 2.640813370936893, 'epoch': 20.0}) 
    
  2. 在基于*《战争与和平》*训练 GPT-2 模型后,我们最终使用它生成了我们自己的版本。我们首先开发了以下函数,用于根据给定的模型和提示文本生成文本:

    >>> def generate_text(prompt_text, model, tokenizer, max_length):
            input_ids = tokenizer.encode(prompt_text, 
                                         return_tensors="pt").to(device)
    
            # Generate response
            output_sequences = model.generate(
                input_ids=input_ids,
                max_length=max_length,
                num_return_sequences=1,
                no_repeat_ngram_size=2,
                top_p=0.9,
            )
            # Decode the generated responses
            responses = []
            for response_id in output_sequences:
                response = tokenizer.decode(response_id,
                                            skip_special_okens=True)
                responses.append(response)
            return responses 
    

在生成过程中,我们首先使用分词器对输入的提示文本进行分词,并将分词后的序列转换为 PyTorch 张量。然后,我们基于分词后的输入使用给定的模型生成文本。在这里,no_repeat_ngram_size防止重复相同的 n-gram 短语,以保持文本的新鲜感。另一个有趣的设置是top_p,它控制生成文本的多样性。它考虑最可能的多个 token,而不仅仅是最可能的一个。最后,我们使用分词器解码生成的响应,将其转换回人类语言。

  1. 我们使用相同的提示文本“the emperor”,以下是我们版本的*《战争与和平》*,共 100 个字:

    >>> prompt_text = "the emperor"
    >>> responses = generate_text(prompt_text, model, tokenizer, 100)
    >>> for response in responses:
            print(response)
    the emperor's, and the Emperor Francis, who was in attendance on him, was present.
    The Emperor was present because he had received the news that the French troops were advancing on Moscow, that Kutuzov had been wounded, the Emperor's wife had died, a letter from Prince Andrew had come from Prince Vasili, Prince Bolkonski had seen at the palace, news of the death of the Emperor, but the most important news was that … 
    

我们成功地使用微调后的 GPT-2 模型生成了我们自己的*《战争与和平》版本。它比 LSTM 版本的第十二章*阅读体验要好。

GPT 是一种仅解码的 Transformer 架构。它的核心是一次生成一个 token。与一些其他基于 Transformer 的模型不同,它不需要额外的步骤(编码)来理解输入的内容。这使得它能够专注于生成自然流畅的文本,像一个创造性的作家。一旦 GPT 在大量文本数据上进行训练,我们可以通过小型数据集对其进行微调,适应特定任务。可以把它想象成教一个大师级的故事讲述者,去写关于特定主题的内容,比如历史或科幻。在我们的例子中,我们通过微调 GPT-2 模型生成了自己的*《战争与和平》*版本。

虽然 BERT 专注于双向预训练,GPT 是自回归的,预测序列中的下一个单词。你可能会想,是否存在一种结合了双向理解和自回归生成的模型?答案是有的——双向和自回归 TransformerBART)。它由 Facebook AI 推出(《BART: 双向和自回归 Transformer 用于序列到序列学习》,Lewis 等,2019 年),旨在结合两者的优势。

总结

本章主要讲解了 Transformer,这是一种强大的神经网络架构,专为序列到序列任务设计。其关键成分——自注意力机制,使模型能够专注于序列中最重要的信息部分。

我们进行了两个 NLP 项目:情感分析和文本生成,使用了两种最先进的 Transformer 模型——BERT 和 GPT。与上一章的工作相比,我们观察到了更高的性能。我们还学习了如何使用 Hugging Face 库微调这些 Transformer 模型,该库是加载预训练模型、执行不同 NLP 任务以及在你自己的数据上微调模型的一站式平台。除此之外,它还提供了一些额外工具,帮助切割文本、检查模型表现,甚至生成一些文本。

在下一章中,我们将聚焦于另一个 OpenAI 的前沿模型——CLIP,并实现基于自然语言的图像搜索。

练习

  1. 你能计算出示例句子“python machine learning”中第三个单词“learning”的位置信息编码吗?请使用一个四维向量。

  2. 你能为主题分类微调一个 BERT 模型吗?你可以以新闻组数据集为例。

  3. 你能微调一个 BART 模型(huggingface.co/facebook/bart-base)来编写你自己的《战争与和平》版本吗?

加入我们书籍的 Discord 空间

加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

packt.link/yuxi

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/QR_Code187846872178698968.png

第十四章:使用 CLIP 构建图像搜索引擎:一种多模态方法

在上一章中,我们重点介绍了 Transformer 模型,如 BERT 和 GPT,利用它们在序列学习任务中的能力。在本章中,我们将探讨一种多模态模型,它能够无缝连接视觉和文本数据。通过其双编码器架构,该模型学习视觉和文本概念之间的关系,使其在涉及图像和文本的任务中表现出色。我们将深入探讨其架构、关键组件和学习机制,并进行模型的实际实现。接下来,我们将构建一个具备文本到图像和图像到图像能力的多模态图像搜索引擎。最后,我们将完成一个令人兴奋的零样本图像分类项目!

本章将涵盖以下主题:

  • 介绍 CLIP 模型

  • 开始使用数据集

  • 构建 CLIP 模型架构

  • 使用文字查找图像

介绍 CLIP 模型

我们在 第十一章《使用卷积神经网络对服装图像进行分类》中探讨了计算机视觉,在 第十二章《使用循环神经网络进行序列预测》和 第十三章《通过 Transformer 模型提升语言理解和生成》中探讨了自然语言处理。在本章中,我们将深入研究一个连接计算机视觉与自然语言处理领域的模型——由 OpenAI 开发的 对比语言-图像预训练CLIP)模型。与传统的专门处理计算机视觉或自然语言处理的模型不同,CLIP 被训练以统一的方式理解这两种 模态(图像和文本)。因此,CLIP 在理解和生成图像与自然语言之间的关系方面表现卓越。

在机器学习/人工智能中,模态是指表示信息的特定方式。常见的模态包括文本、图像、音频、视频,甚至传感器数据。

想深入了解 CLIP 的工作原理吗?让我们一起探索并发现它是如何工作的!

理解 CLIP 模型的机制

CLIP 旨在同时学习图像及其对应文本描述的表示。该模型学习将相似的图像和文本对关联起来,并将不相似的图像和文本对分开。其独特的架构(见下图 Figure 14.1)使其能够在图像与文本描述之间建立语义联系:

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_14_01.png

图 14.1:CLIP 架构(图基于《从自然语言监督中学习可迁移视觉模型》中的图 1:arxiv.org/pdf/2103.00020.pdf

如您所见,它采用了一个双编码器架构,集成了视觉编码器和文本编码器。视觉编码器和文本编码器的输出被投射到一个共享空间中。然后,它基于这些图像-文本对的相似性来评估它们的位置。这个共享的语义空间使得 CLIP 能够执行各种视觉-语言任务,如图像分类、物体检测和图像检索。

以下是 CLIP 模型的关键组件。

视觉编码器

CLIP 中的视觉编码器(也称为图像编码器)负责处理和编码图像输入。它通常实现为卷积神经网络(CNN)。回想一下,CNN 非常适合图像相关任务,因为它们可以有效地捕捉图像中的层次化特征。对于输入图像,视觉编码器的输出是一个固定大小的向量表示。这个嵌入向量捕捉了图像的语义内容。

视觉编码器使用了两种主要架构。第一种版本是基于 ResNet-50 模型的修改版 ResNet 模式。此外,平均池化层被替换为注意力池化机制。这个注意力池化通过多头注意力的单层实现,查询条件是最近引入的视觉变换器(Vision Transformer)https://huggingface.co/google/vit-base-patch16-224)。它在 Transformer 之前还包括了一个额外的归一化层,这是唯一的调整。

需要注意的是,生成的视觉嵌入存在于与文本编码器嵌入共享的空间中。这个共享空间的投射使得视觉和文本表示可以直接进行比较。如果图像和文本描述在语义上相关,它们将在这个空间中被映射到更接近的位置。例如,一张猫的图片和对应的文本“a fluffy cat”将在该空间中靠得很近,表示它们的语义相似性。

视觉编码器在一个包含图像及其相关文本描述的大规模、多样化数据集上进行了预训练。例如,OpenAI 在 CLIP 论文中提到(openai.com/index/clip)他们的模型是在一个包含 4 亿对图像-文本的集合上训练的,这些数据通过爬取互联网获得。预训练过程使得视觉编码器能够学习丰富且通用的视觉表示。此外,所学到的表示是任务无关的。因此,我们可以对 CLIP 模型进行微调,以适应各种文本-图像应用。

文本编码器

同样,文本编码器负责处理和编码文本输入。该过程从分词开始。分词后的文本通过嵌入层转换成固定大小的高维向量。此外,为了保留文本中的重要序列信息,我们会在嵌入中加入位置编码。最终生成的嵌入能够捕捉文本的语义内容。

文本编码器实现为一个具有特定架构的 Transformer。例如,OpenAI 团队使用了一个 12 层、512 宽的模型,配备 8 个注意力头和总共 6300 万个参数,最大序列长度限制为 76。

如前所述,文本嵌入与视觉编码器的嵌入位于共享空间中。这使得视觉和文本输入可以直接比较,实现跨模态理解。同样,在多样化数据集上进行预训练使得模型能够从不同语言背景中学习可泛化的上下文理解。文本编码器可以与视觉编码器协作,针对各种下游任务进行微调。

对比学习

对比学习是 CLIP 中的训练策略。它教会模型区分相似和不相似的图像-文本对。在训练过程中,CLIP 会接收正向和负向的图像-文本对。正向对由语义相关的图像和描述组成;而负向对则是将一张图像与随机选取的描述配对,形成不匹配。

对比学习的核心是将正向对的嵌入拉近共享嵌入空间,同时将负向对的嵌入推得更远。这种分离是通过对比损失函数实现的。让我们来详细分析对比损失的计算:

  1. 嵌入生成:

给定N张图像I和相应的文本描述T,CLIP 模型首先使用其双重编码器(视觉编码器和文本编码器)架构生成图像嵌入!和文本嵌入!

  1. 相似度矩阵计算:

由于嵌入!位于同一空间,我们可以计算成对的相似度S。对于图像i和文本j,它们的相似度!是图像嵌入!与文本嵌入!的余弦相似度:

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_14_008.png

这里,https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_14_009.png。目标是最大化N个正向对的图像和文本嵌入的相似性,同时最小化N² − N个负向配对的嵌入相似性。

  1. 目标矩阵创建:

接下来,我们构造用于学习的目标(“理想”)矩阵 Y这里, https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_14_010.png 如果图像 i 和文本 j正对(对角元素);https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_14_011.png 对于所有其他对(非对角元素)。

  1. 交叉熵损失计算:

给定相似度矩阵 S 和目标矩阵 Y,我们接着计算图像和文本模态的交叉熵损失。这里是图像对齐的损失:

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_14_012.png

它衡量的是模型在给定文本描述的情况下,预测正确图像的能力。

测量模型在给定图像的情况下预测正确描述的文本对齐损失是:

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_14_013.png

  1. 最终损失计算:

对比损失是图像基础损失和文本基础损失的平均值:

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_14_014.png

在训练过程中,模型的参数会被更新以最小化对比损失函数。这驱使模型学习嵌入,使得正确配对的图像和文本对齐,而将不匹配的对分开。

对比学习目标有助于有效的跨模态检索和理解。结合双编码器架构,一个预训练的 CLIP 模型可以执行各种下游的图像-文本任务,而无需特定任务的重新训练。那么,这些典型的应用和场景有哪些呢?让我们接下来看看。

探索 CLIP 模型的应用

在本节中,我们将解释 CLIP 模型的一些常见应用和使用场景。

零样本图像分类

在零样本学习的设置中,CLIP 面对的是一个它没有经过显式训练的任务。例如,它可能被要求将图像分类到从未见过的类别中,或在预训练期间没有见过类似示例的情况下,为图像生成描述。

第一个零样本应用是基于文本描述的图像分类。由于模型的预训练知识,我们不需要进行任何特定任务的训练。模型可以根据图像与描述的对齐情况来对图像进行分类。

例如,给定三张未见过的图像(图像 1:一张红色复古车停在城市街道上的照片;图像 2:一幅描绘红色汽车在城市环境中的画作;图像 3:一幅描绘城市的卡通插画,城市中有一辆红色汽车),我们将查询文本“在城市街道上停放的复古红色汽车”传递给 CLIP 模型。它可能会正确地将 图像 1 排名为与查询最相关的图像,因为它展示了街道上的红色复古车。由于其卡通风格,图像 3 可能会被评为最不相关的图像,因为它与查询的对齐程度最低。

零样本学习能力使得 CLIP 在标签样本稀缺甚至无法获取的任务中也能发挥作用。我们已经看到,它能够为那些在预训练期间从未见过的类别对图像进行分类。在下一节中,我们将使用它进行零样本文本分类。

零样本文本分类

类似地,CLIP 还可以根据图像对新的文本描述进行分类。在零样本设置下,我们不需要为微调提供标注的示例。该模型可以根据文本与图像的匹配度对文本输入进行分类。

例如,给定一张山脉景观的查询图像和三种可能的描述(文本 1:“一览宁静的山脉景观”,“文本 2:“山脉的壮丽峰峦与山谷”,“文本 3:“山区的徒步小道”),CLIP 模型可能会因与查询图像的最高匹配度而将文本 1 评分为最相关的描述。

我们已经讨论过 CLIP 用于零样本图像和文本分类。事实上,我们可以将其扩展到内容检索。接下来让我们看看下一部分。

图像与文本检索

CLIP 可用于根据给定的文本查询检索相关的图像,反之亦然。

例如,在图像检索中,我们将文本查询“顽皮的小狗”传递给图像搜索引擎。CLIP 模型检索出与“顽皮的小狗”描述最匹配的图像,并根据它们与文本查询的匹配度进行排序。类似地,CLIP 也可以用来检索并排序准确描述图像内容的标题。

我们已经展示了 CLIP 的跨模态检索能力。在下一部分,我们将探讨其在跨模态生成中的应用。

图像与文本生成

除了从现有内容池中检索,我们还可以使用 CLIP 基于文本提示生成图像,或为图像提供文本描述。

例如,我们可以通过给 CLIP 模型提供类似“一个机器人骑自行车的超现实主义画作”这样的提示,生成艺术图像。我们还可以要求 CLIP 模型描述一张现代厨房的图片,它可能回答:“一个现代化的厨房设计。”

事实上,CLIP 可以回答关于给定图像的许多问题,不仅仅是提供描述。

视觉问答(VQA)

CLIP 可以被调整用于视觉问答任务。它可以根据对视觉和文本模态的理解,回答关于图像的问题。例如,我们可以用该模型回答诸如“这是什么动物?”或者“照片中有多少人?”之类的问题。

迁移学习

最后,我们可以对 CLIP 进行微调,应用于特定的下游任务,例如物体检测、情感分析和自定义分类任务。

在预训练过程中,CLIP 模型通过多样的图像和文本,获得跨模态的广泛理解。利用迁移学习,我们不需要进行大量任务特定的训练,它可以广泛应用于视觉和 NLP 的各种任务。

迫不及待地想要开始实现 CLIP 吗?让我们从深入了解包含图像和标题的数据集开始,这将是我们训练过程中的基础。

开始使用数据集

我们将使用由 M. Hodosh, P. Young 和 J. Hockenmaier 创建的Flickr8k数据集(hockenmaier.cs.illinois.edu/8k-pictures.html),该数据集在《将图像描述框架化为排序任务:数据、模型与评估指标》一文中有所描述,刊登在《人工智能研究期刊》第 47 卷,853–899 页(www.jair.org/index.php/jair/article/view/10833/25855)。该数据集通常应用于各种计算机视觉任务,尤其是图像标题生成。

Flickr8k数据集包含来自 Flickr 照片共享网站的 8,000 张图像。这些图像涵盖了各种各样的场景、物体和活动。数据集中的每张图像都与五个英文句子相关联,这些句子作为标题,提供了图像内容的文本描述。

Flickr8k数据集的一个常见用途是图像标题生成,目标是训练模型为图像生成类人化的标题。Flickr8k数据集常被研究人员和实践者用作图像标题生成模型的基准。它使我们能够评估模型理解和描述视觉内容的能力,并以自然语言表达。

还有一个扩展版本叫做Flickr30k,它包含了 30,000 张带有对应标题的图像。这个更大的数据集提供了更广泛和多样化的图像集用于训练和评估,但它也消耗更多的计算资源。因此,在本章中我们将重点关注Flickr8k数据集。

获取 Flickr8k 数据集

若要获取Flickr8k数据集,只需在illinois.edu/fb/sec/1713398提交请求。收到请求后,数据集链接将通过电子邮件发送给你。链接之一将引导你下载文件Flickr8k_Dataset.zip,你可以从中提取 8,091 个图像文件。另一个链接会引导你下载名为Flickr8k_text.zip的文件。我们将使用提取的Flickr8k.token.txt文件,其中包含了Flickr8k数据集的原始标题。第一列的格式为“图像路径 # 标题编号”,第二列则是对应的标题。

数据集也可以在 Kaggle 上获取,例如www.kaggle.com/datasets/adityajn105/flickr8k/datacaptions.txt文件包含了与Flickr8k.token.txt文件相似的信息,但更易于使用,因为第一列仅包含图像路径。为了简化操作,我们将使用captions.txt文件,而不是原始的Flickr8k.token.txt文件。

加载 Flickr8k 数据集

在从Flickr8k_Dataset.zip中提取所有图像并准备好标题文本文件后,我们现在可以将Flickr8k数据集加载到自定义的 PyTorch Dataset 对象中。请按照以下步骤进行:

  1. 首先,我们导入必要的软件包:

    >>> import os
    >>> from PIL import Image
    >>> import torch
    >>> from torch.utils.data import Dataset, DataLoader
    >>> import torchvision.transforms as transforms 
    

在这里,Image包将用于加载图像文件。

  1. 我们将图像目录和标题文件路径设置如下:

    >>> image_dir = "flickr8k/Flicker8k_Dataset"
    >>> caption_file = "flickr8k/captions.txt" 
    

在这里,我们将所有提取的图像放入flickr8k/Flicker8k_Dataset文件夹,并将captions.txt文件放在同一根目录flickr8k下。

  1. 接下来,我们加载DistilBRET分词器,就像在上一章中所做的那样,《使用 Transformer 模型推进语言理解与生成》

    >>> from transformers import DistilBertTokenizer
    >>> tokenizer = DistilBertTokenizer.from_pretrained(
                                             'distilbert-base-uncased') 
    
  2. 现在,我们为Flickr8k数据集创建一个自定义的 PyTorch Dataset 类,如下所示:

    >>> class Flickr8kDataset(Dataset):
            def __init__(self, image_dir, caption_file):
                self.image_dir = image_dir
                self.transform = transforms.Compose([
                                    transforms.Resize((224, 224)),
                                    transforms.ToTensor(),
                                 ])
                self.image_paths, self.captions = 
                self.read_caption_file(caption_file)
            def read_caption_file(self, caption_file):
                image_paths = []
                captions = []
                with open(caption_file, "r") as file:
                    lines = file.readlines()
                    for line in lines[1:]:
                        parts = line.strip().split(",")
                         image_paths.append(os.path.join(self.image_dir,
                                                         parts[0]))
                         captions.append(parts[1])
                self.caption_encodings = tokenizer(captions, truncation=True,
                                                   padding=True,
                                                   max_length=200)
                return image_paths, captions
            def __len__(self):
                return len(self.image_paths)
    
            def __getitem__(self, idx):
                item = {key: torch.tensor(val[idx]) for key, val in
                                                self.caption_encodings.items()}
                caption = self.captions[idx]
                item["caption"] = caption
                img_path = self.image_paths[idx]
                img = Image.open(img_path).convert("RGB")
                img = self.transform(img)
                item['image'] = img
                return item 
    

在初始化时,我们使用torchvision中的transforms模块定义一个n图像转换函数,包括将图像调整为(224,224)像素并转换为张量;我们逐行读取标题文件,提取图像路径和标题,并将它们存储在image_pathscaptions列表中。标题使用给定的分词器进行标记化和编码,支持截断、填充以及最大长度为 200 个标记。结果存储在caption_encodings中。

从数据集中获取一个项目时,经过标记化和编码的标题会与原始标题一起存储在item对象中。相应索引的图像也会被加载、转换,并添加到item中。

  1. 我们初始化一个自定义的Dataset类实例,如下所示:

    >>> flickr8k_dataset = Flickr8kDataset(image_dir=image_dir,
                                           caption_file=caption_file) 
    

看看一个数据样本:

>>> item_sample = next(iter(flickr8k_dataset))
{'input_ids': tensor([ 101, 1037, 2775, 1999, 1037, 5061, 4377, 2003, 
        8218, 2039, 1037, 2275, 1997, 5108, 1999, 2019, 4443, 2126, 1012, 
         102,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0, 
           0,    0,    0,    0,    0,    0,    0,    0,    0]),
 'attention_mask': tensor([1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 
           1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
           0, 0, 0]),
 'caption': 'A child in a pink dress is climbing up a set of stairs in an entry way.',
 'image': tensor([[[0.3216, 0.4353, 0.4549,  ..., 0.0157, 0.0235, 0.0235],
          [0.3098, 0.4431, 0.4667,  ..., 0.0314, 0.0275, 0.0471],
          [0.3020, 0.4588, 0.4745,  ..., 0.0314, 0.0275, 0.0392],
          ...,
          [0.7294, 0.5882, 0.6706,  ..., 0.8314, 0.6471, 0.6471],
          [0.6902, 0.6941, 0.8627,  ..., 0.8235, 0.6588, 0.6588],
          [0.8118, 0.8196, 0.7333,  ..., 0.8039, 0.6549, 0.6627]],
         [[0.3412, 0.5020, 0.5255,  ..., 0.0118, 0.0235, 0.0314],
          [0.3294, 0.5059, 0.5412,  ..., 0.0353, 0.0392, 0.0824],
          [0.3098, 0.5176, 0.5529,  ..., 0.0353, 0.0510, 0.0863],
          ...,
          [0.4235, 0.3137, 0.4784,  ..., 0.8667, 0.7255, 0.7216],
          [0.3765, 0.5059, 0.6627,  ..., 0.8549, 0.7216, 0.7216],
          [0.4941, 0.5804, 0.4784,  ..., 0.8392, 0.7216, 0.7216]],
         [[0.3804, 0.4902, 0.4980,  ..., 0.0118, 0.0157, 0.0196],
          [0.3608, 0.5059, 0.5176,  ..., 0.0275, 0.0235, 0.0235],
          [0.3647, 0.5255, 0.5333,  ..., 0.0196, 0.0235, 0.0275],
          ...,
          [0.1216, 0.1098, 0.2549,  ..., 0.9176, 0.8235, 0.7961],
          [0.0784, 0.1804, 0.2902,  ..., 0.9137, 0.8118, 0.7843],
          [0.1843, 0.2588, 0.2824,  ..., 0.9176, 0.8039, 0.7686]]])} 

该标题是一个穿粉色裙子的孩子正在爬上入口处的一组楼梯*。*让我们使用以下脚本显示图像本身:

>>> import matplotlib.pyplot as plt
>>> import numpy as np
>>> npimg = item_sample['image'].numpy()
>>> plt.imshow(np.transpose(npimg, (1, 2, 0))) 

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_14_02.png

图 14.2:Flickr8k 数据样本的图像(照片来源:Rick & Brenda Beerhorst,Flickr:www.flickr.com/photos/studiobeerhorst/1000268201/)

  1. 数据准备的最后一步是创建一个DataLoader对象来处理批次和洗牌。我们将批次大小设置为 32,并基于之前创建的数据集初始化一个DataLoader

    >>> batch_size = 32
    >>> data_loader = DataLoader(flickr8k_dataset, batch_size=batch_size, shuffle=True) 
    

数据集准备好后,让我们继续开发下一节中的 CLIP 模型。

构建 CLIP 模型

视觉编码器和文本编码器是 CLIP 模型的两个主要组成部分。我们将从视觉编码器开始。

视觉编码器

实现视觉编码器非常简单。我们利用 PyTorch 的vision库,它提供了多种预训练的图像模型,包括ResNetsVisionTransformer。在这里,我们选择 ResNet50 作为我们的视觉编码器示例。

视觉编码器确保每个图像被编码为一个固定大小的向量,其维度与模型的输出通道匹配(对于 ResNet50,向量大小为2048):

>>> import torch.nn as nn
>>> from torchvision.models import resnet50
>>> class VisionEncoder(nn.Module):
        def __init__(self):
            super().__init__()
            pretrained_resnet50 = resnet50(pretrained=True)
            self.model = nn.Sequential(*list(
                                       pretrained_resnet50.children())[:-1])
            for param in self.model.parameters():
                param.requires_grad = False
        def forward(self, x):
            x= self.model(x)
            x = x.view(x.size(0), -1)
            return x 

在初始化时,我们加载预训练的 ResNet50 模型。然后,我们去除最终的分类层,因为我们将 ResNet50 模型用作特征提取器,而不是分类器。在这里,我们通过将模型的requires_grad训练属性设置为 false,冻结模型的参数。你也可以通过使参数可训练来微调预训练的 ResNet50 模型组件。forward方法用于从输入图像中提取图像嵌入。

我们刚刚基于预训练的 ResNet50 模型实现了VisionEncoder模块。我们使用模型的隐藏层输出作为每张图像的固定大小向量表示。由于我们忽略了其最终的分类层,在这种情况下,ResNet50 模型被用作图像特征提取器。

接下来,我们将继续处理文本编码器模块。

文本编码器

为了简化,我们将使用 DistilBERT 作为文本编码器。我们通过利用[CLS]标记的最终表示来提取句子的完整表示。预期是这个表示能够捕获句子的整体意义(在本例中是图片的描述)。从概念上讲,这类似于应用于图像的过程,其中图像被转换为固定大小的向量。对于 DistilBERT(以及 BERT),每个标记的输出表示是一个大小为768的向量。

我们使用以下代码实现文本编码器:

>>> from transformers import DistilBertModel
>>> class TextEncoder(nn.Module):
        def __init__(self):
            super().__init__()
            self.model = DistilBertModel.from_pretrained(
                                              'distilbert-base-uncased')
            for param in self.model.parameters():
                param.requires_grad = False
        def forward(self, input_ids, attention_mask=None):
            outputs = self.model(input_ids=input_ids,
                                 attention_mask=attention_mask)
            return outputs.last_hidden_state[:, 0, :] 

在初始化时,我们首先从 Hugging Face Transformers 库加载一个预训练的 DistilBERT 模型。然后,通过将所有参数的requires_grad设置为False,我们冻结 DistilBERT 模型的参数。同样,你也可以通过使参数可训练来微调预训练的 DistilBERT 模型。在前向传播中,我们将输入传入 DistilBERT 模型,并从模型的输出中提取最后的隐藏状态。最后,我们返回与[CLS]标记对应的向量,作为输入描述的嵌入表示。

我们刚刚实现了TextEncoder模块,用于使用 DistilBERT 模型对文本输入进行编码。它使用[CLS]标记表示作为输入文本序列的固定大小向量表示。与我们在视觉编码器中所做的类似,为了简化操作,我们冻结了 DistilBERT 模型中的参数,并将其用作文本特征提取器,而不进行进一步的训练。

对比学习的投影头

在将图像和文本编码为固定大小的向量(图像为 2,048,文本为 768)后,下一步是将它们投影到共享空间中。这个过程使得图像和文本嵌入向量能够进行比较。我们稍后可以训练 CLIP 模型,以区分相关和不相关的图像-文本对。

我们开发了以下头部投影模块,将初始的 2,048 维图像向量或 768 维文本向量转换为共享的 256 维空间:

>>> class ProjectionHead(nn.Module):
        def __init__(self, embedding_dim, projection_dim=256, dropout=0.1):
            super().__init__()
            self.projection = nn.Linear(embedding_dim, projection_dim)
            self.gelu = nn.GELU()
            self.fc = nn.Linear(projection_dim, projection_dim)
            self.dropout = nn.Dropout(dropout)
            self.layer_norm = nn.LayerNorm(projection_dim)
        def forward(self, x):
            projection = self.projection(x)
            x = self.gelu(projection)
            x = self.fc(x)
            x = self.dropout(x)
            x = projection + x
            x = self.layer_norm(x)
            return x 

在这里,我们首先创建一个线性投影层,将输入向量的大小从embedding_dim转换为projection_dim。然后,我们应用高斯误差线性单元GELU)激活函数来引入非线性。接着,我们添加另一个全连接层,并加入一个 dropout 层进行正则化。最后,我们应用层归一化以提高训练效率。

GELU 是一种激活函数,它将非线性引入神经网络。其定义为:

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_14_015.png

与 ReLU 相比,GELU 要复杂得多(如你所见),因此具有更平滑的梯度。此外,GELU 在更深或更复杂的网络中通常表现得比 ReLU 更好。然而,ReLU 由于其简单性和在许多场景中的有效性,仍然保持着广泛的应用。

最佳实践

层归一化被用于深度神经网络中,用于归一化每一层的输入。其目的是提高训练稳定性和模型的泛化能力。与批量归一化不同,批量归一化是对整个数据批次进行归一化,而层归一化是对每个单独的数据样本在特征维度上进行归一化。

对于每一个数据点,层归一化是独立地在各特征上应用的。因此,层归一化对于小批量或在线训练非常有利,而批量归一化则更适合大批量或大数据集。它们都是深度学习中稳定训练的有价值技术。你可以根据数据集大小和批量大小等因素来选择使用哪种方法。

在这个上下文中,embedding_dim表示输入向量的大小(对于图像为 2,048,对于文本为 768),而projection_dim表示输出向量的大小,在我们这个例子中是 256。

总结来说,这个投影头模块的设计是将输入的图像和文本表示向量转换到同一低维空间。除了使用线性投影外,我们还加入了非线性并采用了正则化技术,如 dropout 和层归一化。最终生成的投影向量将成为对比学习的基础构件。接下来,我们将探讨它们如何被用来学习图像和文本之间的语义关系。

CLIP 模型

本节是整个过程的关键!我们将利用之前构建的模块来实现主要的 CLIP 模型,如下所示:

>>> import torch.nn.functional as F
>>> class CLIPModel(nn.Module):
        def __init__(self, image_embedding=2048, text_embedding=768):
            super().__init__()
            self.vision_encoder = VisionEncoder()
            self.text_encoder = TextEncoder()
            self.image_projection = ProjectionHead(embedding_dim=image_embedding)
            self.text_projection = ProjectionHead(embedding_dim=text_embedding)
        def forward(self, batch):
            image_features = self.vision_encoder(batch["image"])
            text_features = self.text_encoder(
                input_ids=batch["input_ids"], 
                attention_mask=batch["attention_mask"]
            )
            image_embeddings = self.image_projection(image_features)
            text_embeddings = self.text_projection(text_features)
            logits = text_embeddings @ image_embeddings.T
            images_similarity = image_embeddings @ image_embeddings.T
            texts_similarity = text_embeddings @ text_embeddings.T
            targets = F.softmax((images_similarity + texts_similarity)/2 , dim=-1)
            texts_loss = F.cross_entropy(logits, targets)
            images_loss = F.cross_entropy(logits.T, targets.T)
            loss = (images_loss + texts_loss) / 2
            return loss.mean() 

初始化过程是显而易见的,我们分别为图像和文本创建VisionEncoderTextEncoder的实例,以及它们对应的头部投影ProjectionHead实例。在前向传递中,我们使用视觉编码器将输入图像编码为固定大小的向量,使用文本编码器将输入文本编码。回想一下,编码后的图像和文本向量的输出大小分别为 2048 和 768。随后,我们使用单独的投影模块将编码向量投影到共享空间中,如前所述。在这个共享空间中,两个编码具有相似的形状(在我们的例子中是 256)。接下来,我们计算对比损失。具体细节如下:

  1. 首先,我们通过矩阵乘法(text_embeddings @ image_embeddings.T)计算文本和图像嵌入的相似度。这里,PyTorch 中的@操作符执行矩阵乘法,或者在此上下文中执行点积操作,而.T是我们之前讨论过的转置操作。回想一下,在线性代数中,计算点积是衡量两个向量相似度的常见方法。更高的结果表示更大的相似度。

  2. 接下来,我们计算图像嵌入之间的相似度,以及文本之间的相似度。

  3. 然后,我们将图像和文本的相似度结合起来,生成目标分布。

  4. 我们计算预测的 logits 与目标分布之间的交叉熵损失,分别针对图像和文本。

  5. 最后,我们将图像和文本损失的平均值作为最终的对比损失。

我们刚刚开发了一个模块来使用对比损失训练 CLIP 模型。该模型接受一个包含图像和文本数据的批次,编码它们,然后将它们投影到共享空间中。接着,计算相似度并计算对比损失。目标是将相似的图像和文本表示拉近,而将不相似的图像和文本表示推远。

现在,所有模块都已准备好,训练 CLIP 模型的时间到了。

用文字找到图片

在本节中,我们将首先训练我们在前几节实现的 CLIP 模型。然后,我们将使用训练好的模型根据查询检索图像。最后,我们将使用预训练的 CLIP 模型进行图像搜索和零-shot 预测。

训练 CLIP 模型

让我们按照以下步骤训练 CLIP 模型:

  1. 首先,我们创建一个 CLIP 模型并将其移动到系统设备(GPU 或 CPU):

    >>> device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
    >>> model = CLIPModel().to(device) 
    
  2. 接下来,我们初始化一个 Adam 优化器来训练模型,并设置学习率:

    >>> optimizer = torch.optim.Adam(model.parameters(), lr=0.001) 
    
  3. 如我们在前几章中所做的那样,我们定义以下训练函数来更新模型:

    >>> def train(model, dataloader, optimizer):
            model.train()
            total_loss = 0
            b = 0
            for batch in dataloader:
                optimizer.zero_grad()
                batch = {k: v.to(device) for k, v in batch.items()
                                             if k != "caption"}
                loss = model(batch)
                optimizer.zero_grad()
                loss.backward()
                optimizer.step()
                total_loss += loss.item()*len(batch)
    
            return total_loss/len(dataloader.dataset) 
    
  4. 我们训练模型三轮:

    >>> num_epochs = 3
    >>> for epoch in range(num_epochs):
            train_loss = train(model, data_loader, optimizer)
            print(f'Epoch {epoch+1} - loss: {train_loss:.4f}')
    Epoch 1 - loss: 0.2551
    Epoch 2 - loss: 0.1504
    Epoch 3 - loss: 0.1274 
    

训练在三轮迭代后完成。现在,让我们使用训练好的 CLIP 模型进行图像搜索。

获取图像和文本的嵌入以识别匹配项

为了找到与文本查询(或反之)匹配的图像,关键过程是获得图像候选和文本查询的投影嵌入。目标是获取在其嵌入和文本嵌入之间达到最高相似度得分的图像。

为了举例说明,我们将使用单个批次的图像数据作为图像候选池。让我们来看看在这个样本池中搜索相关图像的步骤:

  1. 首先,我们从data_loader中采样一个 32 个数据点的批次:

    >>> torch.manual_seed(0)
    >>> data_loader = DataLoader(flickr8k_dataset, batch_size=batch_size,
                                 shuffle=True)
    >>> sample_batch = next(iter(data_loader)) 
    
  2. 接下来,我们使用之前训练的 CLIP 模型计算所采样图像的投影嵌入:

    >>> batch_image_features = model.vision_encoder(sample_batch["image"].to(device))
    >>> batch_image_embeddings = model.image_projection(batch_image_features) 
    
  3. 我们现在定义图像搜索函数,如下所示:

    >>> def search_top_images(model, image_embeddings, query, n=1):
            encoded_query = tokenizer([query])
            batch = {
                key: torch.tensor(values).to(device)
                for key, values in encoded_query.items()
            }
            model.eval()
            with torch.no_grad():
                text_features = model.text_encoder(
                    input_ids=batch["input_ids"],
                    attention_mask=batch["attention_mask"])
                text_embeddings = model.text_projection(text_features)
            dot_similarity = text_embeddings @ image_embeddings.T
            values, indices = torch.topk(dot_similarity.squeeze(0), n)
            return indices 
    

在这里,我们首先计算给定文本查询的投影文本嵌入。接下来,我们计算文本嵌入与每个图像候选的预计算图像嵌入之间的点积相似度。我们检索与最高相似度得分对应的前 n 个索引。别忘了将训练好的模型设置为评估模式,这表示在推理过程中不应计算梯度。

  1. 现在让我们观察它的表现!首先,我们使用刚才定义的图像搜索函数搜索“奔跑的狗”并展示搜索结果:

    >>> query = "a running dog"
    >>> top_image_ids = search_top_images(model, batch_image_embeddings, query, 2)
    >>> print("Query:", query)
    >>> for id in top_image_ids:
            image = sample_batch["image"][id]
            npimg = image.numpy()
            plt.imshow(np.transpose(npimg, (1, 2, 0)))
            plt.title(f"Query: {query}")
            plt.show() 
    

以下截图展示了结果:

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_14_03.png

图 14.3:查询“奔跑的狗”返回的图像(顶部照片由 Ron Mandsager 提供,Flickr: www.flickr.com/photos/remandsager/3540416981/; 底部照片由 Rob Burns-Sweeney 提供,Flickr: www.flickr.com/photos/mulberryphotographic/3368207495/)

这两张检索到的图像与查询高度相关。

  1. 在结束本节之前,让我们尝试另一个查询,“孩子跳进游泳池”:

    >>> query = " kids jumping into a pool "
    >>> top_image_ids = search_top_images(model, batch_image_embeddings, query)
    >>> print("Query:", query)
    >>> for id in top_image_ids:
            image = sample_batch["image"][id]
            npimg = image.numpy()
            plt.imshow(np.transpose(npimg, (1, 2, 0)))
            plt.title(f"Query: {query}")
            plt.show() 
    

以下截图展示了结果:

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_14_04.png

图 14.4:查询“孩子跳进游泳池”返回的图像(照片由 Alecia 提供,Flickr: www.flickr.com/photos/jnjsmom2007/2602415701/)

检索到的图像正是我们要找的。

我们实现的 CLIP 模型采用了预训练的 ResNet50 模型作为视觉编码器,预训练的 DistilBERT 模型作为文本编码器。请记住,我们将 ResNet50 和 DistilBERT 的参数冻结,利用它们作为图像和文本特征提取器。如果需要,您可以通过允许它们的参数可训练来微调这些模型。这将是本章的练习。我们使用Flickr8k数据集训练了我们的 CLIP 模型,并进行了图像搜索作为性能评估。从下一节开始,我们将使用预训练的 CLIP 模型,它从一个更大、更具多样性的数据集学习,用于执行图像搜索、图像到图像的搜索和零样本预测。

使用预训练的 CLIP 模型进行图像搜索

一个流行的库 SentenceTransformers(www.sbert.net/index.html)提供了一个 OpenAI CLIP 模型的封装。SentenceTransformer包是为句子和文本嵌入而开发的。它提供了预训练模型,将句子编码为语义空间中的高维向量。

让我们执行以下任务进行图像搜索,使用从SentenceTransformer获得的预训练 CLIP 模型:

  1. 首先,使用以下命令安装SentenceTransformers库:

    pip install -U sentence-transformers 
    

或者

conda install -c conda-forge sentence-transformers 
  1. 导入SentenceTransformers库并加载预训练的 CLIP 模型:

    >>> from sentence_transformers import SentenceTransformer, util
    >>> model = SentenceTransformer('clip-ViT-B-32') 
    

在这里,我们使用基于Vision TransformerViT)的 CLIP 模型。 "B-32"的标记表示 ViT 模型的大小,这意味着它有比基础 ViT 模型多 32 倍的参数。

  1. 接下来,我们需要使用刚加载的 CLIP 模型,为所有Flickr8k图像候选计算图像嵌入:

    >>> import glob
    >>> image_paths = list(glob.glob('flickr8k/Flicker8k_Dataset/*.jpg'))
    >>> all_image_embeddings = []
    >>> for img_path in image_paths:
            img = Image.open(img_path)
            all_image_embeddings.append(model.encode(img, convert_to_tensor=True)) 
    

model.encode()方法可以接受文本或图像,并生成相应的嵌入。在这里,我们将所有生成的图像嵌入存储在all_image_embeddings中。

  1. 类似于我们在前一部分做的那样,我们定义图像搜索功能如下:

    >>> def search_top_images(model, image_embeddings, query, top_k=1):
            query_embeddings = model.encode([query], convert_to_tensor=True,
                                            show_progress_bar=False)
            hits = util.semantic_search(query_embeddings,  image_embeddings,
                                        top_k=top_k)[0]
            return hits 
    

在这里,我们再次使用model.encode()方法来获取给定查询的嵌入。我们使用util.semantic_search实用程序函数根据嵌入的相似性,获取给定文本查询的前 k 个图像。

  1. 现在,让我们使用刚才定义的图像搜索功能搜索“a swimming dog”并显示搜索结果:

    >>> query = "a swimming dog"
    >>> hits = search_top_images(model, all_image_embeddings, query)
    >>> for hit in hits:
            img_path = image_paths[hit['corpus_id']]
            image = Image.open(img_path)
            plt.imshow(image)
            plt.title(f"Query: {query}")
            plt.show() 
    

以下截图展示了结果:

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_14_05.png

图 14.5:为查询“游泳的狗”检索到的图像(照片来自 Julia,Flickr:www.flickr.com/photos/drakegsd/408233586/)

这个方法非常准确。

  1. 我们可以超越文本到图像的搜索,执行图像到图像的搜索:

    >>> image_query =
           Image.open("flickr8k/Flicker8k_Dataset/240696675_7d05193aa0.jpg") 
    

我们将选择一张随机图像,240696675_7d05193aa0.jpg,作为查询图像,将其输入图像搜索功能,并显示返回的与查询图像相似的图像:

>>> hits = search_top_images(model, all_image_embeddings, image_query, 3)[1:]
>>> plt.imshow(image_query)
>>> plt.title(f"Query image")
>>> plt.show()
>>> for hit in hits:
        img_path = image_paths[hit['corpus_id']]
        image = Image.open(img_path)
        plt.imshow(image)
        plt.title(f"Similar image")       
        plt.show() 

请注意,我们跳过了第一张检索到的图像,因为它就是查询图像。

以下截图展示了结果:

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_14_06.png

图 14.6:查询图像和相似图像(顶部照片来自 Rose,Flickr:www.flickr.com/photos/rosespics/240696675/; 中间照片来自 Mark Dowling,Flickr:www.flickr.com/photos/markdowlrods/421932359/; 底部照片来自 Rob,Flickr:www.flickr.com/photos/mind_the_goat/3419634480/)

我们可以看到,检索到的图像与查询图像高度相似。

预训练的 CLIP 模型在文本到图像和图像到图像的搜索任务中表现出色。值得注意的是,模型可能没有专门在 Flickr8k 数据集上进行训练,但在零样本学习中表现良好,正如您在本节中所看到的。最后,让我们看一个零样本预测的另一个例子——对 CIFAR-100 数据集进行分类。

零样本分类

本章的最后部分,我们将利用 CLIP 模型对 CIFAR-100 数据集进行分类。CIFAR-10CIFAR-100 (www.cs.toronto.edu/~kriz/cifar.html) 都是从 80 Million Tiny Images 数据集中提取的标注子集,该数据集由 Alex Krizhevsky、Vinod Nair 和 Geoffrey Hinton 精心策划。CIFAR-100 数据集包含 60,000 张 32x32 彩色图像,这些图像被分类为 100 个类别,每个类别恰好包含 600 张图像。我们可以直接从 PyTorch 加载数据集,其中包括 50,000 张用于训练的图像和 10,000 张用于测试的图像。

让我们执行以下任务以分类 CIFAR-100 数据集:

  1. 首先,我们从 PyTorch 加载 CIFAR-100 数据集:

    >>> from torchvision.datasets import CIFAR100
    >>> cifar100 = CIFAR100(root="CIFAR100", download=True, train=False) 
    

在这里,我们只加载包含 10,000 个样本的测试子集。

  1. 检查数据集的类别:

    >>> print(cifar100.classes)
    >>> print("Number of classes in CIFAR100 dataset:", len(cifar100.classes))
    ['apple', 'aquarium_fish', 'baby', 'bear', 'beaver', 'bed', 'bee', 'beetle', 'bicycle', 'bottle', 'bowl', 'boy', 'bridge', 'bus', 'butterfly', 'camel', 'can', 'castle', 'caterpillar', 'cattle', 'chair', 'chimpanzee', 'clock', 'cloud', 'cockroach', 'couch', 'crab', 'crocodile', 'cup', 'dinosaur', 'dolphin', 'elephant', 'flatfish', 'forest', 'fox', 'girl', 'hamster', 'house', 'kangaroo', 'keyboard', 'lamp', 'lawn_mower', 'leopard', 'lion', 'lizard', 'lobster', 'man', 'maple_tree', 'motorcycle', 'mountain', 'mouse', 'mushroom', 'oak_tree', 'orange', 'orchid', 'otter', 'palm_tree', 'pear', 'pickup_truck', 'pine_tree', 'plain', 'plate', 'poppy', 'porcupine', 'possum', 'rabbit', 'raccoon', 'ray', 'road', 'rocket', 'rose', 'sea', 'seal', 'shark', 'shrew', 'skunk', 'skyscraper', 'snail', 'snake', 'spider', 'squirrel', 'streetcar', 'sunflower', 'sweet_pepper', 'table', 'tank', 'telephone', 'television', 'tiger', 'tractor', 'train', 'trout', 'tulip', 'turtle', 'wardrobe', 'whale', 'willow_tree', 'wolf', 'woman', 'worm']
    Number of classes in CIFAR100 dataset: 100 
    

共有 100 个类别。

  1. 我们从一个样本开始:

    >>> sample_index = 0
    >>> img, class_id = cifar100[index]
    >>> print(f"Class of the sample image: {class_id} - {cifar100.classes[class_id]}")
    Class of the sample image: 49 - mountain 
    

我们将尝试是否能正确地将其分类为“mountain”(山脉)。

  1. 然后,我们使用预训练的 CLIP 模型生成所选数据样本的图像嵌入:

    >>> sample_image_embeddings = model.encode(img, convert_to_tensor=True) 
    
  2. 现在,这里有一个巧妙的方法。我们将每个类别视为文本描述,目标是找到最合适的描述来对给定图像进行分类。因此,我们需要为 100 个类别生成文本嵌入:

    >>> class_text = model.encode(cifar100.classes, convert_to_tensor=True) 
    
  3. 让我们为给定图像搜索最佳的类别文本描述,如下所示:

    >>> hits = util.semantic_search(sample_image_embeddings,  class_text, top_k=1)[0]
    >>> pred = hits[0]['corpus_id']
    >>> print(f"Predicted class of the sample image: {pred}")  
    Predicted class of the sample image: 49 
    

我们可以正确预测样本图像的类别。那么整个数据集呢?我们将在接下来的步骤中评估其性能。

  1. 同样,我们计算数据集中所有图像的图像嵌入:

    >>> all_image_embeddings = []
    >>> class_true = []
    >>> for img, class_id in cifar100:
            class_true.append(class_id)
            all_image_embeddings.append(model.encode(img, convert_to_tensor=True)) 
    

我们还会记录真实的类别信息。

  1. 现在,我们为每张图像搜索最佳的类别文本描述:

    >>> class_pred = []
    >>> for hit in util.semantic_search(all_image_embeddings,  class_text, top_k=1):
            class_pred.append(hit[0]['corpus_id']) 
    

最后,我们评估分类准确率:

>>> from sklearn.metrics import accuracy_score
>>> acc = accuracy_score(class_true, class_pred)
>>> print(f"Accuracy of zero-shot classification: {acc * 100}%")
Accuracy of zero-shot classification: 55.15% 

我们在 100 类 CIFAR 数据集上获得了 55% 的分类准确率。

本项目演示了 CLIP 如何用于零样本分类。该模型在训练时没有见过特定的图像-标签对,但仍能预测图像的文本标签。根据您的具体使用案例,可以自由调整文本描述和图像。例如,您可以将几个相似的细分类别合并为一个粗类,如将 “boy” (男孩)、“girl”(女孩)、“man”(男人)和“woman”(女人)合并为“people”(人类)。

使用 CLIP 进行零样本分类非常强大,但其性能受限于预训练模型所使用的训练数据。直观地说,可以通过利用在更大数据集上预训练的模型来改善这一点。另一种方法是知识蒸馏,它将复杂且高性能的模型的知识转移到一个更小、更快的模型中。你可以在Distilling the Knowledge in a Neural Network(2015 年),作者 Geoffrey Hinton、Oriol Vinyals 和 Jeff Dean 中阅读更多关于知识蒸馏的内容(arxiv.org/abs/2006.05525)。

总结

本章介绍了 CLIP,这是一种强大的深度学习模型,设计用于跨模态任务,例如根据文本查询查找相关图像或反之亦然。我们了解到,模型的双编码器架构和对比学习机制使其能够在共享空间中理解图像和文本。

我们实现了自定义版本的 CLIP 模型,使用了 DistilBERT 和 ResNet50 模型。在探索Flickr8k 数据集后,我们构建了一个 CLIP 模型,并探索了其在文本到图像和图像到图像搜索中的能力。CLIP 在零样本迁移学习方面表现出色。我们通过使用预训练的 CLIP 模型进行图像搜索和CIFAR-100 分类展示了这一点。

在下一章中,我们将重点介绍第三类机器学习问题:强化学习。你将学习强化学习模型如何通过与环境的互动来实现其学习目标。

练习

  1. 微调我们自实现的 CLIP 模型中使用的预训练 ResNet50 和 DistilBERT 模型。

  2. 你能在 10 类CIFAR-10数据集上进行零样本分类吗?

  3. 使用CIFAR-100 数据集的训练集微调 CLIP 模型,并查看是否能在测试集上获得更好的性能。

参考文献

  • 从自然语言监督中学习可迁移的视觉模型,Alec Radford 等著。

  • Flickr8k 数据集:将图像描述构建为排序任务:数据、模型和评估指标人工智能研究杂志,第 47 卷,第 853–899 页

  • CIFAR-100 数据集:从微小图像中学习多层次特征,Alex Krizhevsky,2009 年。

加入我们书籍的 Discord 讨论区

加入我们社区的 Discord 讨论区,与作者和其他读者交流:

packt.link/yuxi

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/QR_Code1878468721786989681.png

第十五章:在复杂环境中通过强化学习做决策

在上一章,我们专注于图像和文本联合学习的多模态模型。本书的最后一章将讲解强化学习,这是书籍开头提到的第三类机器学习任务。你将看到从经验中学习和与环境互动学习与之前讲解的监督学习和无监督学习的区别。

本章将涵盖以下内容:

  • 设置工作环境

  • 用示例介绍强化学习

  • 使用动态规划解决 FrozenLake 环境问题

  • 执行蒙特卡罗学习

  • 使用 Q-learning 算法解决出租车问题

设置工作环境

让我们开始设置本章所需的工作环境,包括 Gymnasium(它基于 OpenAI Gym),这是一个为你提供各种环境的工具包,帮助你开发学习算法。

介绍 OpenAI Gym 和 Gymnasium

OpenAI Gym 是一个开发和比较强化学习算法的工具包。它提供了一系列环境或“任务”,强化学习智能体可以在这些环境中互动并学习。这些环境从简单的网格世界游戏到复杂的现实世界场景仿真不等,允许研究人员和开发者实验各种强化学习算法。它由 OpenAI 开发,旨在构建安全且有益的 人工通用智能AGI)。

OpenAI Gym 的一些关键特点包括:

  • 环境接口:Gym 提供了一个一致的接口,用于与环境进行交互,使得智能体能够观察状态、采取行动并获得奖励(我们将在后文学习这些术语)。

  • 丰富的环境集合:Gym 提供了多样化的环境集合,包括经典控制任务、Atari 游戏、机器人仿真等。这使得研究人员和开发者能够在各个领域评估算法。

  • 易用的 API:Gym 的 API 简单易用,适合初学者和有经验的研究人员。开发者可以快速原型化并测试强化学习算法,利用 Gym 提供的环境。

  • 基准测试:Gym 通过提供标准化的环境和评估指标,促进了基准测试。这使得研究人员能够比较不同算法在常见任务上的表现。

  • 社区贡献:Gym 是一个开源项目,社区积极贡献新的环境、算法和扩展功能到该工具包中。这一合作努力帮助不断扩展和改进 Gym 的能力。

总体而言,OpenAI Gym 为强化学习社区提供了一个宝贵的资源,提供了一个标准化的平台用于研究、实验和基准测试。

Gym 曾是一个开创性的库,并且在许多年里为简洁性设定了标准。然而,它不再由 OpenAI 团队积极维护。意识到这一点,一些开发者主动创建了Gymnasiumgymnasium.farama.org/index.html),并得到了 OpenAI 的批准。Gymnasium 作为 Gym 的继任者应运而生,OpenAI 的原始开发者偶尔会为其开发做出贡献,确保其可靠性和持续性。在本章中,我们将使用 Gymnasium,这是 Gym 的维护分支

安装 Gymnasium

安装 Gymnasium 库的一种方式是通过pip,如下所示:

pip install gymnasium 

推荐使用以下命令安装toy-text扩展:

pip install gymnasium [toy-text] 

toy-text扩展提供了额外的基于文本的玩具环境,例如 FrozenLake 环境(稍后讨论),供强化学习实验使用。

安装后,您可以通过运行以下代码来检查可用的 Gymnasium 环境:

>>> import gymnasium as gym
>>> print(gym.envs.registry.keys())
dict_keys(['CartPole-v0', 'CartPole-v1', 'MountainCar-v0', 'MountainCarContinuous-v0', 'Pendulum-v1', 'Acrobot-v1', 'phys2d/CartPole-v0', 'phys2d/CartPole-v1', 'phys2d/Pendulum-v0', 'LunarLander-v2', 'LunarLanderContinuous-v2', 'BipedalWalker-v3', 'BipedalWalkerHardcore-v3', 'CarRacing-v2', 'Blackjack-v1', 'FrozenLake-v1', 'FrozenLake8x8-v1', 'CliffWalking-v0', 'Taxi-v3', 'tabular/Blackjack-v0', 'tabular/CliffWalking-v0', 'Reacher-v2', 'Reacher-v4', 'Pusher-v2', 'Pusher-v4', 'InvertedPendulum-v2', 'InvertedPendulum-v4', 'InvertedDoublePendulum-v2', 'InvertedDoublePendulum-v4', 'HalfCheetah-v2', 'HalfCheetah-v3', 'HalfCheetah-v4', 'Hopper-v2', 'Hopper-v3', 'Hopper-v4', 'Swimmer-v2', 'Swimmer-v3', 'Swimmer-v4', 'Walker2d-v2', 'Walker2d-v3', 'Walker2d-v4', 'Ant-v2', 'Ant-v3', 'Ant-v4', 'Humanoid-v2', 'Humanoid-v3', 'Humanoid-v4', 'HumanoidStandup-v2', 'HumanoidStandup-v4', 'GymV21Environment-v0', 'GymV26Environment-v0']) 

您可以在gymnasium.farama.org/environments/toy_text/gymnasium.farama.org/environments/atari/查看环境列表,包括行走、登月、赛车和 Atari 游戏。您可以通过访问gymnasium.farama.org/content/basic_usage/来轻松体验 Gymnasium 的介绍。

为了对不同的强化学习算法进行基准测试,我们需要在一个标准化的环境中应用它们。Gymnasium 是完美的选择,提供了多种通用环境。这类似于在监督学习和无监督学习中使用 MNIST、ImageNet 和汤森路透新闻等数据集作为基准。

Gymnasium 为强化学习环境提供了一个易于使用的接口,我们可以编写智能体与之交互。那么,什么是强化学习?什么是智能体?让我们在下一节中看看。

通过示例介绍强化学习

在本章中,我们将首先介绍强化学习的元素,并通过一个有趣的例子来讲解,接着介绍如何衡量来自环境的反馈,并介绍解决强化学习问题的基本方法。

强化学习的元素

你可能在小时候玩过超级马里奥(或索尼克)。在这个视频游戏中,你控制马里奥同时收集金币并避开障碍物。如果马里奥撞到障碍物或掉进空隙,游戏就结束了,你会尽力在游戏结束前收集尽可能多的金币。

强化学习与超级马里奥游戏非常相似。强化学习就是学习该做什么。它涉及观察环境中的情况,并确定正确的行动以最大化数值奖励。

以下是强化学习任务中各元素的列表(我们还将每个元素与超级马里奥和其他示例相连接,以便更容易理解):

  • 环境:环境是一个任务或模拟。在超级马里奥游戏中,游戏本身就是环境。在自动驾驶中,道路和交通就是环境。在围棋下棋的上下文中,棋盘是环境。环境的输入是来自智能体的行动,输出是发给智能体的状态奖励

  • 智能体:智能体是根据强化学习模型采取行动的组件。它与环境互动,并观察状态,将其输入到模型中。智能体的目标是解决环境问题——也就是说,找到一组最佳行动来最大化奖励。在超级马里奥游戏中,智能体是马里奥,在自动驾驶中,自动驾驶汽车就是智能体。

  • 行动:这是智能体可能的移动。在强化学习任务的开始阶段,当模型开始学习环境时,行动通常是随机的。超级马里奥的可能行动包括向左和向右移动、跳跃和蹲下。

  • 状态:状态是来自环境的观察结果。它们在每个时间步上以数字方式描述当前的情况。在国际象棋游戏中,状态是棋盘上所有棋子的位置信息。在超级马里奥中,状态包括马里奥的位置和时间帧内的其他元素。在一个学习走路的机器人中,它的两条腿的位置就是状态。

  • 奖励:每当智能体采取一个行动时,它会从环境中获得数值反馈,这个反馈叫做奖励。奖励可以是正数、负数或零。超级马里奥中的奖励可以是,例如,马里奥收集到一个金币时是+1,避开一个障碍物是+2,撞到障碍物是-10,或者在其他情况下为 0。

下图总结了强化学习的过程:

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_15_01.png

图 15.1:强化学习过程

强化学习过程是一个迭代循环。一开始,智能体从环境中观察到初始状态,s[0]。然后,智能体根据模型采取行动,a[0]。在智能体移动后,环境进入一个新的状态,s[1],并给予反馈奖励,R[1]。然后,智能体根据模型使用s[1]和R[1]作为输入,采取行动,a[1]。这个过程会继续,直到终止、完成,或者永远持续下去。

强化学习模型的目标是最大化总奖励。那么,我们如何计算总奖励呢?是不是通过将所有时间步的奖励加起来?让我们在下一节中看看。

累积奖励

在时间步* t *时,累积奖励(也叫回报G[1]可以表示为:

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_15_001.png

这里,T是终止时间步长或无限。G[t]表示在时间t采取行动a[t]之后的未来总奖励。在每个时间步长t,强化学习模型试图学习最佳的行动,以最大化G[t]。

然而,在许多现实世界的情况下,事情并非总是这样简单,我们只是把所有未来奖励加总起来。看看以下例子:

股票 A 在第 1 天结束时上涨 6 美元,第 2 天结束时下跌 5 美元。股票 B 在第 1 天下跌 5 美元,第 2 天上涨 6 美元。经过两天后,两只股票都上涨了 1 美元。那么,如果我们知道这一点,我们在第 1 天开始时会选择买哪只股票?显然是股票 A,因为我们不会亏损,甚至可以在第 2 天开始时卖出赚取 6 美元的利润。

两只股票的总奖励相同,但我们更倾向于股票 A,因为我们更关心即时回报而非远期回报。同样,在强化学习中,我们会对远期奖励进行折扣,折扣因子与时间跨度有关。较长的时间跨度应该对累积奖励的影响较小。这是因为较长的时间跨度包含了更多无关信息,从而具有更高的方差。

我们定义一个折扣因子*!*,其值介于 0 和 1 之间。我们重写累积奖励,结合折扣因子:

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_15_003.png

如你所见,越大,折扣越小,反之亦然。如果,则实际上没有折扣,模型会根据所有未来奖励的总和来评估一个行动。如果*!,模型只关注即时奖励R*[t][+1]。

现在我们知道如何计算累积奖励,接下来要讨论的是如何最大化它。

强化学习的方法

解决强化学习问题的主要方法有两种,目的是寻找最优的行动来最大化累积奖励。一种是基于策略的方法,另一种是基于价值的方法。

基于策略的方法

策略是一个函数 https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_15_007.png,它将每个输入状态映射到一个行动:

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_15_008.png

它可以是确定性的或随机的:

  • 确定性:从输入状态到输出行动是单一映射

  • 随机性:这给出了所有可能行动的概率分布!

基于策略的方法中,模型学习最优策略,将每个输入状态映射到最佳行动。智能体直接学习针对它所遇到的任何情况(状态)的最佳行动(策略)。

在基于策略的算法中,模型从一个随机的策略开始。然后计算该策略的价值函数,这一步称为策略评估步骤。之后,基于价值函数,找到一个新的、更优的策略,这就是策略改进步骤。这两个步骤会不断重复,直到找到最优策略。

假设你正在训练一名赛车手。在基于策略的方法中,你直接教导赛车手在赛道的不同部分(状态)上采取最佳操作(策略),以实现最快的圈速(奖励)。你不会告诉他们每个弯道的估计结果(奖励),而是通过反馈和练习指导他们朝着最优赛车路线前进。

基于价值的方法

状态的价值 V 被定义为从该状态开始收集的预期未来累计奖励:

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_15_010.png

基于价值的方法中,模型学习最大化输入状态价值的最优价值函数。换句话说,智能体采取行动到达能够实现最大价值的状态。

在基于价值的算法中,模型从随机的价值函数开始。然后通过迭代的方式找到一个新的、更优的价值函数,直到达到最优价值函数。

现在,假设你是一个寻宝猎人。在基于价值的方法中,你学习迷宫中不同位置(状态)的估计宝藏(价值)。这帮助你选择通向宝藏(奖励)潜力更高区域的路径,而无需预定义的行动路线(策略)。

我们已经了解到,解决强化学习问题有两种主要的方法。在下一部分中,我们将分别通过基于策略和基于价值的方式,使用动态规划方法来解决一个具体的强化学习示例(FrozenLake)。

使用动态规划解决 FrozenLake 环境

本节将重点讨论基于策略和基于价值的动态规划算法。但我们首先通过模拟 FrozenLake 环境开始。它模拟了一个简单的网格世界场景,其中智能体在一个冰面地形的网格中导航,目标是到达目标格子。

模拟 FrozenLake 环境

FrozenLake 是一个典型的 OpenAI Gym(现为 Gymnasium)环境,具有离散状态。它的目标是在一个网格中将智能体从起始格子移动到目标格子,同时避开陷阱。网格的大小为 4 * 4(FrozenLake-v1)或 8 * 8(FrozenLake8x8-v1)。网格中有四种类型的格子:

  • 起始格子:这是状态 0,且奖励为 0。

  • 目标格子:它是 4 * 4 网格中的状态 15,提供 +1 奖励并结束一个回合。

  • 冰冻格子:在 4 * 4 网格中,状态 1、2、3、4、6、8、9、10、13 和 14 是可走的格子,给予 0 奖励。

  • 洞方格:在 4 * 4 的网格中,状态 5、7、11 和 12 是洞方格。它会给予 0 奖励并终止剧集。

这里,剧集指的是强化学习环境的模拟。它包含从初始状态到终止状态的状态列表、一系列动作和奖励。在 4 * 4 的 FrozenLake 环境中,代理可以移动到 16 个方格中的任何一个,因此有 16 个可能的状态。而可选的动作有四种:向左移动(0)、向下移动(1)、向右移动(2)和向上移动(3)。

这个环境的棘手之处在于,由于冰面滑溜,代理不会总是朝着它打算的方向移动,而是可能会朝任何其他可行方向移动,或者停在原地,且这些都有一定的概率。例如,即使代理打算向上移动,它也可能会向右移动。

现在让我们按照以下步骤模拟 4 * 4 的 FrozenLake 环境:

  1. 要模拟任何 OpenAI Gym 环境,我们需要先在文档中查找其名称,地址为gymnasium.farama.org/environments/toy_text/frozen_lake/。在我们的例子中,我们得到FrozenLake-v1

  2. 我们导入gym库并创建一个FrozenLake实例:

    >>> env = gym.make("FrozenLake-v1" , render_mode="rgb_array")
    >>> n_state = env.observation_space.n
    >>> print(n_state)
    16
    >>> n_action = env.action_space.n
    >>> print(n_action)
    4 
    

环境使用FrozenLake-v1标识符进行初始化。此外,render_mode参数设置为rgb_array,表示环境应将其状态渲染为 RGB 数组,适合于可视化目的。

我们还获取了环境的维度。

  1. 每次我们运行一个新的剧集时,都需要重置环境:

    >>> env.reset()
    (0, {'prob': 1}) 
    

这意味着代理从状态 0 开始。同样,存在 16 个可能的状态,0、1、…、15。

  1. 我们渲染环境以显示它:

    >>> import matplotlib.pyplot as plt
    >>> plt.imshow(env.render()) 
    

如果遇到错误,您可以安装pyglet库,该库通过画布渲染将Matplotlib图形嵌入窗口,使用以下命令:

pip install pyglet 

您将看到一个 4 * 4 的矩阵,代表 FrozenLake 网格,以及代理所在的方格(状态 0):

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_15_02.png

图 15.2:FrozenLake 的初始状态

  1. 现在让我们开始移动代理。我们选择向右移动,因为那是可行的:

    >>> new_state, reward, terminated, truncated, info = env.step(2)
    >>> is_done = terminated or truncated
    >>> env.render()
    >>> print(new_state)
    4
    >>> print(reward)
    0.0
    >>> print(is_done)
    False
    >>> print(info)
    {'prob': 0.3333333333333333} 
    

我们执行一个“向右”(2)动作,但代理以 33.33%的概率向下移动到状态 4,并获得 0 奖励,因为剧集尚未完成。

让我们看看渲染结果:

>>> plt.imshow(env.render()) 

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_15_03.png

图 15.3:代理向右移动的结果

您可能会得到完全不同的结果,因为代理有 33.33%的概率直接移动到状态 1,或者由于冰面滑溜的特性,33.33%的概率停留在状态 0。

在 Gymnasium 中,"terminated"和"truncated"指的是强化学习环境中剧集结束的不同方式。当一个剧集被 terminated 时,意味着该剧集按照环境规则自然结束。当一个剧集被 truncated 时,意味着剧集在未自然结束之前被人工终止。

  1. 接下来,我们定义一个函数,用于模拟在给定策略下的 FrozenLake 游戏,并返回总奖励(作为简单的开始,我们假设折扣因子为 https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_15_011.png):

    >>> def run_episode(env, policy):
    ...     state, _= env.reset()
    ...     total_reward = 0
    ...     is_done = False
    ...     while not is_done:
    ...         action = policy[state].item()
    ...         state, reward, terminated, truncated, info = env.step(action)
    ...         is_done = terminated or truncated
    ...         total_reward += reward
    ...         if is_done:
    ...             break
    ...     return total_reward 
    

这里,policy是一个 PyTorch 张量,.item()用于提取张量中元素的值。

  1. 现在让我们使用随机策略来与环境进行互动。我们将实现一个随机策略(其中采取随机动作),并计算 1,000 次游戏中的平均总奖励:

    >>> import torch
    >>> n_episode = 1000
    >>> total_rewards = []
    >>> for episode in range(n_episode):
    ...     random_policy = torch.randint(high=n_action, size=(n_state,))
    ...     total_reward = run_episode(env, random_policy)
    ...     total_rewards.append(total_reward)
    ...
    >>> print(f'Average total reward under random policy:
              {sum(total_rewards)/n_episode}')
    Average total reward under random policy: 0.016 
    

平均而言,如果我们采取随机动作,智能体到达目标的概率为 1.6%。这告诉我们,解决 FrozenLake 环境并不像你想象的那么容易。

  1. 作为附加步骤,您可以查看转移矩阵。转移矩阵 T(s, a, s’)包含从状态s采取动作a到达状态*s’*的概率。以状态 6 为例:

    >>> print(env.env.P[6])
    {0: [(0.3333333333333333, 2, 0.0, False), (0.3333333333333333, 5, 0.0, True), (0.3333333333333333, 10, 0.0, False)], 1: [(0.3333333333333333, 5, 0.0, True), (0.3333333333333333, 10, 0.0, False), (0.3333333333333333, 7, 0.0, True)], 2: [(0.3333333333333333, 10, 0.0, False), (0.3333333333333333, 7, 0.0, True), (0.3333333333333333, 2, 0.0, False)], 3: [(0.3333333333333333, 7, 0.0, True), (0.3333333333333333, 2, 0.0, False), (0.3333333333333333, 5, 0.0, True)]} 
    

返回字典的键 0、1、2、3 表示四个可能的动作。每个键的值是一个与动作相关联的元组列表。元组的格式是(转移概率, 新状态,奖励,是否为终止状态)。例如,如果智能体打算从状态 6 采取动作 1(向下),它将以 33.33%的概率移动到状态 5(H),并获得 0 奖励,随后游戏结束;它还将以 33.33%的概率移动到状态 10,获得 0 奖励;它还将以 33.33%的概率移动到状态 7(H),获得 0 奖励并终止游戏。

在本节中,我们已经尝试了随机策略,结果只有 1.6%的成功率。但这为下一节做好了准备,在那里我们将使用基于值的动态规划算法——值迭代算法,来找到最优策略。

使用值迭代算法解决 FrozenLake 问题

值迭代是一种迭代算法。它从随机的策略值V开始,然后根据贝尔曼最优性方程en.wikipedia.org/wiki/Bellman_equation)反复更新值,直到这些值收敛。

值完全收敛通常是困难的。因此,存在两种收敛标准。一个是设定固定的迭代次数,比如 1,000 次或 10,000 次。另一个是设定一个阈值(比如 0.0001 或 0.00001),当所有值的变化小于该阈值时,我们终止过程。

重要的是,在每次迭代中,算法并不是对所有动作的值取期望(平均值),而是选择最大化策略值的动作。迭代过程可以表示如下:

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_15_012.png

这是贝尔曼方程在状态值函数 V(s)中的表示。这里,https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_15_013.png是最优值函数;https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_15_014.png表示从状态s采取行动a后,转移到状态https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_15_015.png的转移概率;而https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_15_016.png是在状态https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_15_017.png下,通过采取行动a所获得的奖励。

一旦我们获得最优值,就可以相应地轻松计算出最优策略:

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_15_018.png

让我们使用值迭代算法来求解 FrozenLake 环境,如下所示:

  1. 首先,我们设置0.99为折扣因子,0.0001为收敛阈值:

    >>> gamma = 0.99
    >>> threshold = 0.0001 
    
  2. 我们开发了值迭代算法,该算法计算最优值:

    >>> def value_iteration(env, gamma, threshold):
    ...     """
    ...     Solve a given environment with value iteration algorithm
    ...     @param env: Gymnasium environment
    ...     @param gamma: discount factor
    ...     @param threshold: the evaluation will stop once values for all states are less than the threshold
    ...     @return: values of the optimal policy for the given environment
    ...     """
    ...     n_state = env.observation_space.n
    ...     n_action = env.action_space.n
    ...     V = torch.zeros(n_state)
    ...     while True:
    ...         V_temp = torch.empty(n_state)
    ...         for state in range(n_state):
    ...             v_actions = torch.zeros(n_action)
    ...             for action in range(n_action):
    ...                 for trans_prob, new_state, reward, _ in \
                                           env.env.P[state][action]:
    ...                     v_actions[action] += trans_prob * (
                                         reward + gamma * V[new_state])
    ...             V_temp[state] = torch.max(v_actions)
    ...         max_delta = torch.max(torch.abs(V - V_temp))
    ...         V = V_temp.clone()
    ...         if max_delta <= threshold:
    ...             break
    ...     return V 
    

value_iteration函数执行以下任务:

  • 初始时,策略值设置为全 0

  • 基于贝尔曼最优性方程更新值:

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_15_019.png

  • 计算所有状态之间值的最大变化

  • 如果最大变化大于收敛阈值,则继续更新值

  • 否则,终止迭代过程,并将最后的值作为最优值返回

  1. 我们应用该算法解决 FrozenLake 环境,并使用指定的参数:

    >>> V_optimal = value_iteration(env, gamma, threshold) 
    

看一下结果中的最优值:

>>> print('Optimal values:\n', V_optimal)
Optimal values:
tensor([0.5404, 0.4966, 0.4681, 0.4541, 0.5569, 0.0000, 0.3572, 0.0000, 0.5905, 0.6421, 0.6144, 0.0000, 0.0000, 0.7410, 0.8625, 0.0000]) 
  1. 由于我们已经得到了最优值,可以从这些值中提取最优策略。我们开发了以下函数来实现这一点:

    >>> def extract_optimal_policy(env, V_optimal, gamma):
    ...     """
    ...     Obtain the optimal policy based on the optimal values
    ...     @param env: Gymnasium environment
    ...     @param V_optimal: optimal values
    ...     @param gamma: discount factor
    ...     @return: optimal policy
    ...     """
    ...     n_state = env.observation_space.n
    ...     n_action = env.action_space.n
    ...     optimal_policy = torch.zeros(n_state)
    ...     for state in range(n_state):
    ...         v_actions = torch.zeros(n_action)
    ...         for action in range(n_action):
    ...             for trans_prob, new_state, reward, _ in
                                       env.env.P[state][action]:
    ...                 v_actions[action] += trans_prob * (
                               reward + gamma * V_optimal[new_state])
    ...         optimal_policy[state] = torch.argmax(v_actions)
    ...     return optimal_policy 
    
  2. 然后,我们根据最优值获得最优策略:

    >>> optimal_policy = extract_optimal_policy(env, V_optimal, gamma) 
    

看一下结果中的最优策略:

>>> print('Optimal policy:\n', optimal_policy)
Optimal policy:
tensor([0., 3., 3., 3., 0., 0., 0., 0., 3., 1., 0., 0., 0., 2., 1., 0.]) 

这意味着,在状态 0 中最优动作是 0(左),在状态 1 中是 3(上),依此类推。如果你看这个网格,可能会觉得这不太直观。但记住,网格是滑的,智能体可能会朝着与期望方向不同的方向移动。

  1. 如果你怀疑这是否是最优策略,可以使用该策略运行 1,000 次实验,并通过检查平均奖励来评估其效果,具体如下:

    >>> def run_episode(env, policy):
            state, _ = env.reset()
            total_reward = 0
            is_done = False
            while not is_done:
                action = policy[state].item()
                state, reward, terminated, truncated, info = env.step(action)
                is_done = terminated or truncated
                total_reward += reward
                if is_done:
                    break
            return total_reward
    >>> n_episode = 1000
    >>> total_rewards = []
    >>> for episode in range(n_episode):
    ...     total_reward = run_episode(env, optimal_policy)
    ...     total_rewards.append(total_reward) 
    

这里,我们定义了run_episode函数来模拟一次实验。然后,我们打印出 1,000 次实验的平均奖励:

>>> print('Average total reward under the optimal policy:', sum(total_rewards) / n_episode)
Average total reward under the optimal policy: 0.738 

值迭代算法保证在有限状态和动作空间的有限环境中收敛到最优值函数。它为解决强化学习问题中的最优策略提供了一种计算上高效的方法,尤其是当环境的动态已知时。在值迭代算法计算出的最优策略下,智能体在 FrozenLake 中 74%的时间能够到达目标方块。

最佳实践

折扣因子是强化学习中的一个重要参数,尤其对于基于价值的模型来说。较高的因子(接近 1)使智能体优先考虑长期奖励,导致更多的探索,而较低的因子(接近 0)则使其关注即时奖励。折扣因子的典型调优策略包括网格搜索和随机搜索。这两者在大范围内可能计算开销较大。自适应调优是另一种方法,其中我们在训练过程中动态调整折扣因子。你可以从一个中等的值(例如 0.9)开始。如果智能体似乎过于关注即时奖励、快速收敛并忽视探索,尝试增加折扣因子。如果智能体不断探索而无法确定良好的策略,尝试减少折扣因子。

我们能否通过基于策略的方法做类似的事情?我们将在下一节中看到。

使用策略迭代算法解决 FrozenLake 问题

策略迭代算法有两个组成部分:策略评估和策略改进。与价值迭代类似,它从一个任意的策略开始,并进行多次迭代。

在每次迭代的策略评估步骤中,我们首先计算最新策略的值,基于贝尔曼期望方程

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_15_020.png

在策略改进步骤中,我们基于最新的策略值推导出改进后的策略,依然基于贝尔曼最优性方程

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_15_021.png

这两步会重复直到策略收敛。收敛时,最新的策略及其价值即为最优策略和最优价值。

让我们开发策略迭代算法,并使用它来解决 FrozenLake 环境,如下所示:

  1. 我们从policy_evaluation函数开始,计算给定策略的值:

    >>> def policy_evaluation(env, policy, gamma, threshold):
    ...  """
    ...     Perform policy evaluation
    ...     @param env: Gymnasium environment
    ...     @param policy: policy matrix containing actions and
            their probability in each state
    ...     @param gamma: discount factor
    ...     @param threshold: the evaluation will stop once values
            for all states are less than the threshold
    ...     @return: values of the given policy
    ...  """
    ...     n_state = policy.shape[0]
    ...     V = torch.zeros(n_state)
    ...     while True:
    ...         V_temp = torch.zeros(n_state)
    ...         for state in range(n_state):
    ...             action = policy[state].item()
    ...             for trans_prob, new_state, reward, _ in \
                                         env.env.P[state][action]:
    ...                 V_temp[state] += trans_prob * 
                                         (reward + gamma * V[new_state])
    ...         max_delta = torch.max(torch.abs–V - V_temp))
    ...         V = V_temp.clone()
    ...         if max_delta <= threshold:
    ...             break
    ...     return V 
    

这个函数执行以下任务:

  • 用全 0 初始化策略值

  • 基于贝尔曼期望方程更新值

  • 计算所有状态下值的最大变化

  • 如果最大变化大于阈值,它会继续更新值

  • 否则,它终止评估过程并返回最新的值

  1. 接下来,我们在以下函数中开发第二个组件,即策略改进:

    >>> def policy_improvement(env, V, gamma):
    ...  """"""
    ...     Obtain an improved policy based on the values
    ...     @param env: Gymnasium environment
    ...     @param V: policy values
    ...     @param gamma: discount factor
    ...     @return: the policy
    ...  """"""
    ...     n_state = env.observation_space.n
    ...     n_action = env.action_space.n
    ...     policy = torch.zeros(n_state)
    ...     for state in range(n_state):
    ...         v_actions = torch.zeros(n_action)
    ...         for action in range(n_action):
    ...             for trans_prob, new_state, reward, _ in
                                          env.env.P[state][action]:
    ...                 v_actions[action] += trans_prob * (
                                      reward + gamma * V[new_state])
    ...         policy[state] = torch.argmax(v_actions)
    ...     return policy 
    

它基于贝尔曼最优性方程从输入的策略值中推导出一个新的、更好的策略。

  1. 有了这两个组件,我们现在开发整个策略迭代算法:

    >>> def policy_iteration(env, gamma, threshold):
    ...  """
    ...     Solve a given environment with policy iteration algorithm
    ...     @param env: Gymnasium environment
    ...     @param gamma: discount factor
    ...     @param threshold: the evaluation will stop once values for all states are less than the threshold
    ...     @return: optimal values and the optimal policy for the given environment
    ...  """
    ...     n_state = env.observation_space.n
    ...     n_action = env.action_space.n
    ...     policy = torch.randint(high=n_action,
                                   size=(n_state,)).float()
    ...     while True:
    ...         V = policy_evaluation(env, policy, gamma, threshold)
    ...         policy_improved = policy_improvement(env, V, gamma)
    ...         if torch.equal(policy_improved, policy):
    ...             return V, policy_improved
    ...         policy = policy_improved 
    

这个函数执行以下任务:

  • 初始化一个随机策略

  • 执行策略评估以更新策略值

  • 执行策略改进以生成新的策略

  • 如果新策略与旧策略不同,它会更新策略,并运行另一次策略评估和改进迭代

  • 否则,它终止迭代过程并返回最新的策略及其值

  1. 接下来,我们使用策略迭代来解决 FrozenLake 环境:

    >>> V_optimal, optimal_policy = policy_iteration(env, gamma, threshold) 
    
  2. 最后,我们显示最优策略及其值:

    >>> print('Optimal values'\n', V_optimal)
    Optimal values:
    tensor([0.5404, 0.4966, 0.4681, 0.4541, 0.5569, 0.0000, 0.3572, 0.0000, 0.5905, 0.6421, 0.6144, 0.0000, 0.0000, 0.7410, 0.8625, 0.0000])
    >>> print('Optimal policy'\n', optimal_policy)
    Optimal policy:
    tensor([0., 3., 3., 3., 0., 0., 0., 0., 3., 1., 0., 0., 0., 2., 1., 0.]) 
    

我们得到了与价值迭代算法相同的结果。

最佳实践

基于策略的方法依赖于估计期望奖励相对于策略参数的梯度。实际上,我们使用像 REINFORCE 这样的技术,它使用简单的蒙特卡洛估计,以及近端策略优化PPO)采用稳定的梯度估计。你可以在这里阅读更多内容:professional.mit.edu/course-catalog/reinforcement-learning第八章策略梯度方法)。

我们刚刚通过策略迭代算法解决了 FrozenLake 环境。你可能会想知道如何在价值迭代和策略迭代算法之间做选择。请查看以下表格:

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_15_04.png

表 15.4:选择策略迭代和价值迭代算法之间的区别

我们通过动态规划方法解决了强化学习问题。这些方法需要环境的完全已知的转移矩阵和奖励矩阵,而且对于具有大量状态的环境,其可扩展性有限。在下一部分,我们将继续学习蒙特卡洛方法,它不需要环境的先验知识,且具有更好的可扩展性。

执行蒙特卡洛学习

蒙特卡洛MC)强化学习是一种无模型的方法,这意味着它不需要已知的转移矩阵和奖励矩阵。在这一部分,你将了解在 Gymnasium 的 21 点环境中进行蒙特卡洛策略评估,并通过蒙特卡洛控制算法解决该环境。21 点是一个典型的具有未知转移矩阵的环境。首先让我们模拟 21 点环境。

模拟 21 点环境

21 点是一款流行的卡片游戏,游戏规则如下:

  • 玩家与庄家对战,若玩家手中卡片的总值高于庄家的且不超过 21 点,则玩家获胜。

  • 2 到 10 的牌值为 2 到 10。

  • J、K 和 Q 牌的价值为 10。

  • 一张王牌的价值可以是 1 或 11(称为“可用”王牌)。

  • 开始时,双方各发两张随机牌,但庄家的牌只有一张对玩家公开。玩家可以请求更多的卡片(称为要牌)或停止继续要牌(称为停牌)。在玩家决定停牌前,如果他们的卡片总和超过 21 点(称为爆牌),则玩家会输。停牌后,庄家会继续抽牌,直到其卡片总和达到 17 点。如果庄家卡片的总和超过 21 点,玩家获胜。如果双方都没有爆牌,则点数较高的一方获胜,或者可能平局。

Gymnasium 中的 21 点环境(gymnasium.farama.org/environments/toy_text/blackjack/)的公式如下:

  • 环境的回合开始时,每方各发两张牌,且仅观察到庄家的其中一张牌。

  • 如果出现胜利或平局,回合结束。

  • 如果玩家赢了,本回合的最终奖励为 +1;如果玩家输了,奖励为 -1;如果平局,奖励为 0。

  • 在每一轮中,玩家可以选择两种动作之一,摸牌(1)或停牌(0)。

现在让我们模拟 Blackjack 环境,并探索它的状态和动作:

  1. 首先,创建一个 Blackjack 实例:

    >>> env = gym.make('Blackjack'v1') 
    
  2. 重置环境:

    >>> env.reset(seed=0)
    ((11, 10, False), {}) 
    

它返回初始状态(一个三维向量):

  • 玩家当前的点数(此例中为 11

  • 赌场公开牌的点数(此例中为 10

  • 是否有可用的 A 牌(此例中为 False

只有当玩家拥有一张可以算作 11 点而不会爆掉的 A 牌时,usable ace 变量才为 True。如果玩家没有 A 牌,或者虽然有 A 牌但已经爆掉,则该状态变量会变为 False

以另一个状态示例 (18, 6, True) 来看,表示玩家拥有一张算作 11 点的 A 牌和一张 7 点,而庄家的公开牌是 6 点。

  1. 现在让我们采取一些动作,看看环境是如何运作的。首先,由于我们只有 11 点,我们选择摸牌动作:

    >>> env.step(1)
    ((12, 10, False), 0.0, False, False, {}) 
    

它返回一个状态 (12, 10, False),奖励为 0,并且该回合未结束(即 False)。

  1. 由于我们只有 12 点,让我们再摸一张牌:

    >>> env.step(1)
    ((13, 10, False), 0.0, False, False, {}) 
    
  2. 我们有 13 点,认为已经足够了。于是我们通过停牌动作(0)停止抽牌:

    >>> env.step(0)
    ((13, 10, False), -1.0, True, False, {}) 
    

赌场获得一些牌并击败了玩家。所以玩家输了,获得了 -1 的奖励。回合结束。

随时可以在 Blackjack 环境中进行尝试。一旦你熟悉了环境,就可以进入下一节,进行简单策略上的 MC 策略评估。

执行蒙特卡罗策略评估

在上一节中,我们应用了动态规划来进行策略评估,也就是策略的价值函数。然而,在大多数现实情况中,由于转移矩阵事先不可知,这种方法无法使用。在这种情况下,我们可以使用 MC 方法来评估价值函数。

为了估计价值函数,MC 方法使用经验均值回报,而不是期望回报(如在动态规划中)。有两种方法来计算经验均值回报。一个是首次访问方法,它仅对所有回合中状态 s首次出现进行回报平均。另一个是每次访问方法,它对所有回合中状态 s每次出现进行回报平均。

显然,首次访问方法计算量较小,因此使用得更为广泛。本章只介绍首次访问方法。

在本节中,我们实验了一种简单策略,直到总点数达到 18(或 19,或 20,如果你愿意的话)才停止抽牌。我们对这种简单策略进行了首次访问 MC 评估,如下所示:

  1. 我们首先需要定义一个函数,模拟在简单策略下的 Blackjack 回合:

    >>> def run_episode(env, hold_score):
    ...     state , _ = env.reset()
    ...     rewards = []
    ...     states = [state]
    ...     while True:
    ...         action = 1 if state[0] < hold_score else 0
    ...         state, reward, terminated, truncated, info = env.step(action)
    ...         is_done = terminated or truncated
    ...         states.append(state)
    ...         rewards.append(reward)
    ...         if is_done:
    ...             break
    ...     return states, rewards 
    

在每轮回合中,如果当前得分小于hold_score,代理将选择“补牌”,否则选择“停牌”。

  1. 在 MC 设置中,我们需要跟踪所有步骤中的状态和奖励。在首次访问值评估中,我们只对所有回合中状态首次出现时的回报进行平均。我们定义一个函数来评估简单的 Blackjack 策略,使用首次访问 MC:

    >>> from collections import defaultdict
    >>> def mc_prediction_first_visit(env, hold_score, gamma, n_episode):
    ...     V = defaultdict(float)
    ...     N = defaultdict(int)
    ...     for episode in range(n_episode):
    ...         states_t, rewards_t = run_episode(env, hold_score)
    ...         return_t = 0
    ...         G = {}
    ...         for state_t, reward_t in zip(
                               states_t[1::-1], rewards_t[::-1]):
    ...             return_t = gamma * return_t + reward_t
    ...             G[state_t] = return_t
    ...         for state, return_t in G.items():
    ...             if state[0] <= 21:
    ...                 V[state] += return_t
    ...                 N[state] += 1
    ...     for state in V:
    ...         V[state] = V[state] / N[state]
    ...     return V 
    

该函数执行以下任务:

  • 使用run_episode函数在简单 Blackjack 策略下运行n_episode个回合

  • 对于每个回合,计算每个状态首次访问时的G回报

  • 对于每个状态,通过从所有回合中对其首次返回值进行平均来获得该状态的值

  • 返回结果值

请注意,在这里我们忽略玩家爆牌的状态,因为我们知道这些状态的值为-1

  1. 我们将hold_score指定为18,折扣率指定为1,因为 Blackjack 回合较短,并且将模拟 500,000 个回合:

    >>> hold_score = 18
    >>> gamma = 1
    >>> n_episode = 500000 
    
  2. 现在我们将所有变量代入,以执行 MC 首次访问评估:

    >>> value = mc_prediction_first_visit(env, hold_score, gamma, n_episode) 
    

然后我们打印出结果值:

>>> print(value)
defaultdict(<class 'float'>, {(13, 10, False): -0.2743235693191795, (5, 10, False): -0.3903118040089087, (19, 7, True): 0.6293800539083558, (17, 7, True): -0.1297709923664122, (18, 7, False): 0.4188926663428849, (13, 7, False): -0.04472843450479233, (19, 10, False): -0.016647081864473168, (12, 10, False): -0.24741546832491254, (21, 10, True):
……
……
……
2, 2, True): 0.07981220657276995, (5, 5, False): -0.25877192982456143, (4, 9, False): -0.24497991967871485, (15, 5, True): -0.011363636363636364, (15, 2, True): -0.08379888268156424, (5, 3, False): -0.19078947368421054, (4, 3, False): -0.2987012987012987}) 

我们刚刚计算了所有可能的 280 个状态的值:

>>> print('Number of states:', len(value))
Number of states: 280 

我们刚刚在 Blackjack 环境下使用 MC 方法计算了 280 个状态在简单策略下的值。Blackjack 环境的转移矩阵事先是未知的。此外,如果我们采用动态规划方法,获取转移矩阵(大小为 280 * 280)将非常昂贵。在基于 MC 的解决方案中,我们只需要模拟一系列的回合并计算经验平均值。以类似的方式,我们将在下一节中搜索最优策略。

执行在线蒙特卡洛控制

MC 控制用于为转移矩阵未知的环境寻找最优策略。MC 控制有两种类型,在线策略和离线策略。在在线策略方法中,我们执行策略并迭代地评估和改进它;而在离线策略方法中,我们使用由其他策略生成的数据训练最优策略。

在本节中,我们专注于在线策略方法。其工作原理与策略迭代方法非常相似。它在以下两个阶段之间迭代:评估和改进,直到收敛:

  • 在评估阶段,我们不再评估状态值,而是评估动作值,通常称为Q 值。Q 值 Q(s, a)是状态-动作对(s, a)在给定策略下,采取动作a时的值。评估可以采用首次访问或每次访问的方式进行。

  • 在改进阶段,我们通过在每个状态中分配最优动作来更新策略:

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_15_022.png

现在我们通过遵循以下步骤,使用在线 MC 控制搜索最优的 Blackjack 策略:

  1. 我们首先开发一个函数,通过在给定的 Q 值下执行最佳动作来完成一局游戏:

    >>> def run_episode(env, Q, n_action):
    ...     state, _ = env.reset()
    ...     rewards = []
    ...     actions = []
    ...     states = []
    ...     action = torch.randint(0, n_action, [1]).item()
    ...     while True:
    ...         actions.append(action)
    ...         states.append(state)
    ...         state, reward, terminated, truncated, info = env.step(action)
    ...         is_done = terminated or truncated
    ...         rewards.append(reward)
    ...         if is_done:
    ...             break
    ...         action = torch.argmax(Q[state]).item()
    ...     return states, actions, rewards 
    

这作为改进阶段。具体来说,它执行以下任务:

  1. 接下来,我们开发了基于策略的蒙特卡罗控制算法:

    >>> def mc_control_on_policy(env, gamma, n_episode):
    ...     G_sum = defaultdict(float)
    ...     N = defaultdict(int)
    ...     Q = defaultdict(lambda: torch.empty(env.action_space.n))
    ...     for episode in range(n_episode):
    ...         states_t, actions_t, rewards_t =
                           run_episode(env,  Q,  env.action_space.n)
    ...         return_t = 0
    ...         G = {}
    ...         for state_t, action_t, reward_t in zip(state_t[::-1], 
                                                       actions_t[::-1],
                                                       rewards_t[::-1]):
    ...             return_t = gamma * return_t + reward_t
    ...             G[(state_t, action_t)] = return_t
    ...         for state_action, return_t in G.items():
    ...             state, action = state_action
    ...             if state[0] <= 21:
    ...                 G_sum[state_action] += return_t
    ...                 N[state_action] += 1
    ...                 Q[state][action] =
                              G_sum[state_action] / N[state_action]
    ...     policy = {}
    ...     for state, actions in Q.items():
    ...         policy[state] = torch.argmax(actions).item()
    ...     return Q, policy 
    

这个函数执行以下任务:

  • 随机初始化 Q 值

  • 运行n_episode个情节

  • 对于每个情节,执行策略改进并获取训练数据;对生成的状态、动作和奖励执行首次访问策略评估;并更新 Q 值

  • 最后,确定最优的 Q 值和最优策略

  1. 现在 MC 控制函数已经准备好,我们计算最优策略:

    >>> gamma = 1
    >>> n_episode = 500000
    >>> optimal_Q, optimal_policy = mc_control_on_policy(env, gamma, n_episode) 
    

看一下最优策略:

>>> print(optimal_policy)
{(16, 8, True): 1, (11, 2, False): 1, (15, 5, True): 1, (14, 9, False): 1, (11, 6, False): 1, (20, 3, False): 0, (9, 6, False):
0, (12, 9, False): 0, (21, 2, True): 0, (16, 10, False): 1, (17, 5, False): 0, (13, 10, False): 1, (12, 10, False): 1, (14, 10, False): 0, (10, 2, False): 1, (20, 4, False): 0, (11, 4, False): 1, (16, 9, False): 0, (10, 8,
……
……
1, (18, 6, True): 0, (12, 2, True): 1, (8, 3, False): 1, (13, 3, True): 0, (4, 7, False): 1, (18, 8, True): 0, (6, 5, False): 1, (17, 6, True): 0, (19, 9, True): 0, (4, 4, False): 0, (14, 5, True): 1, (12, 6, True): 0, (4, 9, False): 1, (13, 4, True): 1, (4, 8, False): 1, (14, 3, True): 1, (12, 4, True): 1, (4, 6, False): 0, (12, 5, True): 0, (4, 2, False): 1, (4, 3, False): 1, (5, 4, False): 1, (4, 1, False): 0} 

你可能想知道这个最优策略是否真的是最优的,并且比之前的简单策略(在 18 点时停止)更好。让我们分别在最优策略和简单策略下模拟 100,000 个 21 点情节:

  1. 我们从模拟简单策略下的一个情节的函数开始:

    >>> def simulate_hold_episode(env, hold_score):
    ...     state, _ = env.reset()
    ...     while True:
    ...         action = 1 if state[0] < hold_score else 0
    ...         state, reward, terminated, truncated, info = env.step(action)
    ...         is_done = terminated or truncated
    ...         if is_done:
    ...             return reward 
    
  2. 接下来,我们在最优策略下工作的模拟函数:

    >>> def simulate_episode(env, policy):
    ...     state, _ = env.reset()
    ...     while True:
    ...         action = policy[state]
    ...         state, reward, terminated, truncated, info = env.step(action)
    ...         is_done = terminated or truncated
    ...         if is_done:
    ...             return reward 
    
  3. 然后我们运行了 100,000 个情节,分别记录它们的胜利次数:

    >>> n_episode = 100000
    >>> hold_score = 18
    >>> n_win_opt = 0
    >>> n_win_hold = 0
    >>> for _ in range(n_episode):
    ...     reward = simulate_episode(env, optimal_policy)
    ...     if reward == 1:
    ...         n_win_opt += 1
    ...     reward = simulate_hold_episode(env, hold_score)
    ...     if reward == 1:
    ...         n_win_hold += 1 
    

我们按以下方式打印结果:

>>> print(f'Winning probability under the simple policy: {n_win_hold/n_episode}')
Winning probability under the simple policy: 0.40256
>>>print(f'Winning probability under the optimal policy: {n_win_opt/n_episode}')
Winning probability under the optimal policy: 0.43148 

在最优策略下玩的胜率为 43%,而在简单策略下只有 40%的胜率。

在这一部分中,我们使用无模型算法 MC 学习解决了 21 点环境。在 MC 学习中,Q 值会在情节结束时进行更新。这可能对长时间过程有问题。在接下来的部分中,我们将讨论 Q-learning,它会在情节中的每一步更新 Q 值。你将看到它如何提高学习效率。

使用 Q-learning 算法解决 21 点问题

Q-learning 也是一种无模型学习算法。它在情节中的每一步更新 Q 函数。我们将展示如何使用 Q-learning 来解决 21 点环境。

引入 Q-learning 算法

Q-learning是一种离策略学习算法,它基于由行为策略生成的数据优化 Q 值。行为策略是一个贪婪策略,它采取在给定状态下获得最高回报的动作。行为策略生成学习数据,目标策略(我们尝试优化的策略)根据以下方程更新 Q 值:

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_15_024.png

在这里,https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_15_025.png 是从状态 s 执行动作 a 后得到的结果状态,r 是相关奖励。https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_15_026.png 表示行为策略在给定状态 https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_15_027.png 下生成的最高 Q 值。超参数 https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_15_028.pnghttps://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_15_029.png 分别是学习率和折扣因子。Q 学习方程基于当前的 Q 值、即时奖励和智能体通过在下一个状态中采取最佳动作所能期望的未来潜在奖励,来更新一个状态-动作对的 Q 值(估计的未来奖励)。

从另一个策略生成的经验中学习,使得 Q 学习能够在每个回合的每一步优化其 Q 值。我们从贪心策略中获得信息,并立即使用这些信息更新目标值。

还有一点需要注意的是,目标策略是 epsilon 贪心策略,这意味着它以概率 https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_15_030.png(值从 0 到 1)采取随机动作,并以概率 https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_15_031.png 采取贪心动作。epsilon 贪心策略结合了利用探索:它在探索不同动作的同时,利用最佳动作。

开发 Q 学习算法

现在是时候开发 Q 学习算法来解决 Blackjack 环境了:

  1. 我们从定义 epsilon 贪心策略开始:

    >>> def epsilon_greedy_policy(n_action, epsilon, state, Q):
    ...     probs = torch.ones(n_action) * epsilon / n_action
    ...     best_action = torch.argmax(Q[state]).item()
    ...     probs[best_action] += 1.0 - epsilon
    ...     action = torch.multinomial(probs, 1).item()
    ...     return action 
    

给定 https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_15_032.png 可能的动作,每个动作的选择概率为 https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_15_033.png,具有最高状态-动作值的动作会以额外的概率 https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_15_034.png 被选择。

  1. 我们将从一个较大的探索因子开始 https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_15_035.png,随着时间的推移逐渐减少,直到其值达到0.1。我们将起始值和最终值 https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_15_036.png 定义如下:

    >>> epsilon = 1.0
    >>> final_epsilon = 0.1 
    
  2. 接下来,我们实现 Q 学习算法:

    >>> def q_learning(env, gamma, n_episode, alpha, epsilon, final_epsilon):
            n_action = env.action_space.n
            Q = defaultdict(lambda: torch.zeros(n_action))
            epsilon_decay = epsilon / (n_episode / 2)
            for episode in range(n_episode):
                state, _ = env.reset()
                is_done = False
                epsilon = max(final_epsilon, epsilon - epsilon_decay)
                while not is_done:
                    action = epsilon_greedy_policy(n_action, epsilon, state, Q)
                    next_state, reward, terminated, truncated, info = env.step(action)
                    is_done = terminated or truncated
                    delta = reward + gamma * torch.max(
                                               Q[next_state]) - Q[state][action]
                    Q[state][action] += alpha * delta
                    total_reward_episode[episode] += reward
                    if is_done:
                        break
                    state = next_state
            policy = {}
            for state, actions in Q.items():
                policy[state] = torch.argmax(actions).item()
            return Q, policy 
    

我们初始化动作值函数 Q,并计算 epsilon 贪心探索策略的 epsilon 衰减率。在每个回合中,我们让智能体按照 epsilon 贪心策略采取行动,并根据离策略学习方程在每一步更新 Q 函数。探索因子也会随着时间推移而减少。我们运行n_episode个回合,最后通过选择每个状态下的最大 Q 值的动作,从学到的动作值函数 Q 中提取最优策略。

  1. 然后,我们初始化一个变量来存储每个 10,000 个回合的表现,通过奖励来衡量:

    >>> n_episode = 10000
    >>> total_reward_episode = [0] * n_episode 
    
  2. 最后,我们执行 Q 学习以获得 Blackjack 问题的最优策略,使用以下超参数:

    >>> gamma = 1
    >>> alpha = 0.003
    >>> optimal_Q, optimal_policy = q_learning(env, gamma, n_episode, alpha,
                                               epsilon, final_epsilon) 
    

在这里,折扣率 https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_15_037.png 和学习率 https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_15_038.png 用于更大的探索。

  1. 在学习了 10,000 个回合后,我们绘制每个回合的滚动平均奖励,结果如下:

    >>> rolling_avg_reward = [total_reward_episode[0]]
    >>> for i, reward in enumerate(total_reward_episode[1:], 1):
            rolling_avg_reward.append((rolling_avg_reward[-1]*i + reward)/(i+1))
    >>> plt.plot(rolling_avg_reward)
    >>> plt.title('Average reward over time')
    >>> plt.xlabel('Episode')
    >>> plt.ylabel('Average reward')
    >>> plt.ylim([-1, 1])
    >>> plt.show() 
    

请参见以下截图获取最终结果:

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_15_05.png

图 15.5:每回合的平均奖励

平均奖励在整个训练过程中稳步上升,最终收敛。这表明,模型在训练后已经能够熟练地解决问题。

  1. 最后,我们模拟了 100,000 轮比赛,验证了我们使用 Q-learning 获得的最优策略,并计算了获胜机会:

    >>> n_episode = 100000
    >>> n_win_opt = 0
    >>> for _ in range(n_episode):
    ...     reward = simulate_episode(env, optimal_policy)
    ...     if reward == 1:
    ...         n_win_opt += 1
    >>>print(f'Winning probability under the optimal policy: {n_win_opt/n_episode}')
    Winning probability under the optimal policy: 0.42398 
    

在最优策略下玩耍有 42% 的获胜机会。

在这一部分,我们通过离策略 Q-learning 解决了 Blackjack 问题。该算法通过从贪婪策略生成的经验中学习,在每一步优化 Q 值。

总结

本章首先配置了工作环境,然后探讨了强化学习的核心概念,并结合实际案例进行了讲解。接着,我们深入研究了 FrozenLake 环境,使用动态规划技术,如价值迭代和策略迭代,有效地解决了该问题。随后,我们在 Blackjack 环境中引入了蒙特卡洛学习,用于价值估计和控制。最后,我们实现了 Q-learning 算法来解决同样的问题,全面回顾了强化学习技术。

练习

  1. 你能尝试使用价值迭代或策略迭代算法解决 8 * 8 FrozenLake 环境吗?

  2. 你能实现每次访问的 MC 策略评估算法吗?

加入我们书籍的 Discord 空间

加入我们的社区 Discord 空间,与作者和其他读者进行讨论:

packt.link/yuxi

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/QR_Code187846872178698968.png

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/New_Packt_Logo1.png

packt.com

订阅我们的在线数字图书馆,全面访问超过 7,000 本书籍和视频,以及行业领先的工具,帮助你规划个人发展并推进职业生涯。欲了解更多信息,请访问我们的网站。

为什么订阅?

  • 节省学习时间,更多时间进行编码,来自 4,000 多名行业专业人士的实用电子书和视频

  • 通过为你量身定制的技能计划,提升你的学习效率

  • 每月免费获取一本电子书或视频

  • 完全可搜索,便于轻松获取重要信息

  • 复制、粘贴、打印并收藏内容

www.packt.com,你还可以阅读一系列免费的技术文章,注册各种免费的电子邮件通讯,并享受 Packt 书籍和电子书的独家折扣和优惠。

你可能喜欢的其他书籍

如果你喜欢这本书,你可能对 Packt 出版的其他书籍感兴趣:

https://www.packtpub.com/en-us/product/mastering-pytorch-9781801074308

《精通 PyTorch - 第二版》

Ashish Ranjan Jha

ISBN:978-1-80107-430-8

  • 使用 PyTorch 实现文本、视觉和音乐生成模型

  • 在 PyTorch 中构建深度 Q 网络(DQN)模型

  • 在移动设备(Android 和 iOS)上部署 PyTorch 模型

  • 熟练掌握使用 fastai 在 PyTorch 中进行快速原型设计

  • 使用 AutoML 高效执行神经架构搜索

  • 使用 Captum 轻松解释机器学习模型

  • 设计 ResNet、LSTM 和图神经网络(GNN)

  • 使用 Hugging Face 创建语言和视觉转换模型

https://www.packtpub.com/en-us/product/building-llm-powered-applications-9781835462317

构建 LLM 驱动的应用程序

瓦伦蒂娜·阿尔托

ISBN: 978-1-83546-231-7

  • 探索 LLM 架构的核心组件,包括编码器-解码器块和嵌入层

  • 了解 GPT-3.5/4、Llama 2 和 Falcon LLM 等 LLM 的独特特性

  • 使用 AI 协调器,如 LangChain,结合 Streamlit 进行前端开发

  • 熟悉 LLM 的组件,如内存、提示和工具

  • 学习如何使用非参数知识和向量数据库

  • 理解 LFMs 对 AI 研究和行业应用的影响

  • 通过微调定制你的 LLM

  • 了解 LLM 驱动应用程序的伦理影响

Packt 正在寻找像你这样的作者

如果你有兴趣成为 Packt 的作者,请访问authors.packtpub.com并今天就申请。我们已经与成千上万的开发者和技术专家合作,帮助他们将见解分享给全球技术社区。你可以提交一个通用申请,申请我们正在招聘作者的特定热门话题,或者提交你自己的创意。

分享你的想法

现在你已经完成了*《Python 机器学习实例 - 第四版》*,我们非常希望听到你的想法!如果你是通过亚马逊购买的这本书,请点击这里直接前往亚马逊的评论页面,分享你的反馈或在购买网站上留下评论。

你的评论对我们和技术社区非常重要,能帮助我们确保提供优质的内容。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值