Github-DevOps-加速指南-全-

Github DevOps 加速指南(全)

原文:annas-archive.org/md5/677f27c30764b3701bc2b6cf6de3a30e

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

2017 年是自然语言处理NLP)的一个分水岭,基于 Transformer 和注意力机制的网络崭露头角。过去几年对于 NLP 的变革性影响,就像 2012 年 AlexNet 对计算机视觉的影响一样巨大。NLP 取得了巨大的进展,我们现在正在从研究实验室走向应用。

这些进展跨越了自然语言理解NLU)、自然语言生成NLG)和自然语言互动NLI)等领域。鉴于这些领域中有如此多的研究,要理解 NLP 的激动人心的进展可能是一项艰巨的任务。

本书专注于 NLP、语言生成和对话系统领域的前沿应用。它涵盖了使用流行库(如 Stanford NLP 和 spaCy)对文本进行预处理的概念,如分词、词性POS)标注和词形还原。命名实体识别NER)模型通过构建双向长短期记忆网络BiLSTMs)、条件随机场CRFs)和维特比解码器从零开始实现。采用非常实用、聚焦应用的视角,本书还涵盖了生成文本以用于句子补全和文本摘要、多模态网络通过为图像生成说明文字连接图像和文本、以及管理聊天机器人对话方面的内容。本书还讲解了 NLP *期进展背后的一个重要原因——迁移学习和微调。未标注的文本数据容易获取,但对这些数据进行标注是昂贵的。本书提供了简化文本数据标注的实用技巧。

到本书结束时,我希望您能掌握用于解决复杂 NLP 问题的工具、技术和深度学习架构的高级知识。本书将涵盖编码器-解码器网络、长短期记忆网络LSTMs)和 BiLSTMs、CRFs、BERT、GPT-2、GPT-3、Transformer 等关键技术,并使用 TensorFlow 进行讲解。

本书还涵盖了构建高级模型所需的高级 TensorFlow 技术:

  • 构建自定义模型和层

  • 构建自定义损失函数

  • 实现学习率退火

  • 使用tf.data高效加载数据

  • 模型检查点以支持长时间训练(通常需要几天)

本书包含可根据您自身使用场景进行调整的可工作代码。我希望通过本书的学习,您甚至能够利用所学技能进行前沿的研究。

本书适合哪些人群

本书假设读者对深度学习的基础知识和自然语言处理(NLP)的基本概念有所了解。本书重点讲解高级应用和构建能够解决复杂任务的 NLP 系统。各种读者都能够跟随本书的内容,但从本书中受益最大的读者包括:

  • 熟悉监督学习和深度学习技术基础的中级机器学习ML)开发者

  • 已经使用 TensorFlow/Python 进行数据科学、机器学习、研究、分析等工作的专业人士,并能从更扎实的高级 NLP 技术理解中获益

本书内容概述

第一章NLP 基础知识,概述了 NLP 中的各种话题,如分词、词干提取、词形还原、词性标注、向量化等。本章将介绍常见的 NLP 库,如 spaCy、Stanford NLP 和 NLTK,并提供它们的关键功能和使用场景。我们还将构建一个简单的垃圾邮件分类器。

第二章使用 BiLSTM 理解自然语言中的情感,涵盖了情感分析的自然语言理解(NLU)用例,介绍了循环神经网络RNNs)、LSTM 和 BiLSTM,它们是现代 NLP 模型的基本构建模块。我们还将使用tf.data高效利用 CPU 和 GPU,提升数据流水线和模型训练的速度。

第三章使用 BiLSTM、CRF 和维特比解码的命名实体识别(NER),聚焦于命名实体识别(NER)这一自然语言理解(NLU)的核心问题,NER 是任务导向型聊天机器人的基本构建模块。我们将为 CRF 构建一个自定义层,以提高 NER 的准确性,并介绍维特比解码方案,它通常应用于深度模型中,以提高输出质量。

第四章使用 BERT 进行迁移学习,涵盖了现代深度 NLP 中的若干重要概念,如迁移学习的类型、预训练词向量、Transformer 的概述,以及 BERT 及其在改进第二章中情感分析任务中的应用,使用 BiLSTM 理解自然语言中的情感

第五章使用 RNN 和 GPT-2 生成文本,专注于使用自定义的基于字符的 RNN 生成文本,并通过 Beam Search 进行改进。我们还将介绍 GPT-2 架构,并简要讨论 GPT-3。

第六章使用 Seq2seq 注意力机制和 Transformer 网络进行文本摘要,挑战性地探讨了抽象文本摘要任务。BERT 和 GPT 是完整编码器-解码器模型的两部分。我们将它们结合在一起,构建一个用于生成新闻文章标题的 seq2seq 模型。还会介绍如何使用 ROUGE 指标评估摘要效果。

第七章使用 ResNet 和 Transformer 进行多模态网络和图像描述,将计算机视觉和 NLP 结合在一起,看看一张图片是否真能表达千言万语!我们将从零开始构建一个自定义的 Transformer 模型,并训练它生成图像的描述。

第八章使用 Snorkel 进行分类的弱监督学习,聚焦于一个关键问题——数据标注。虽然 NLP 领域有大量未标注的数据,但标注数据是一项非常昂贵的任务。本章介绍了snorkel库,并展示如何快速标注大量数据。

第九章使用深度学习构建对话式 AI 应用程序,结合了书中讲解的各种技术,展示了如何构建不同类型的聊天机器人,如问答机器人或槽位填充机器人。

第十章代码安装和配置说明,介绍了安装和配置系统以运行本书随附代码的所有必要步骤。

为了最大限度地利用本书

  • 了解深度学习模型和 TensorFlow 的基础知识是个不错的主意。

  • 强烈建议使用 GPU。一些模型,尤其是后续章节中的模型,规模较大且复杂。它们在 CPU 上训练可能需要数小时甚至数天。没有 GPU 的情况下,RNN 的训练速度非常慢。你可以在 Google Colab 上免费访问 GPU,相关操作说明在第一章中提供。

下载示例代码文件

本书的代码包托管在 GitHub 上,网址为 github.com/PacktPublishing/Advanced-Natural-Language-Processing-with-TensorFlow-2。我们还提供了来自我们丰富图书和视频目录的其他代码包,网址为 github.com/PacktPublishing/。请查看!

下载彩色图片

我们还提供了一个 PDF 文件,包含本书中使用的截图/图表的彩色图片。你可以在这里下载:static.packt-cdn.com/downloads/9781800200937_ColorImages.pdf

使用的约定

本书中使用了许多文本约定。

CodeInText:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟网址、用户输入和 Twitter 用户名。例如:“在 num_capitals() 函数中,执行了对英语中大写字母的替换。”

代码块如下所示:

en = snlp.Pipeline(lang='en')
def word_counts(x, pipeline=en):
  doc = pipeline(x)
  count = sum([len(sentence.tokens) for sentence in doc.sentences])
  return count 

当我们希望引起你对代码块中特定部分的注意时,相关的行或项将以粗体显示:

en = snlp.Pipeline(lang='en')
def word_counts(x, pipeline=en):
  **doc = pipeline(x)**
  count = sum([len(sentence.tokens) for sentence in doc.sentences])
  return count 

任何命令行输入或输出都将如下所示:

!pip install gensim 

粗体:表示一个新术语、一个重要的词或你在屏幕上看到的词,例如在菜单或对话框中,也会以这种方式出现在文本中。例如:“从 管理 面板中选择 系统信息。”

警告或重要说明将以这种形式出现。

提示和技巧将以这种形式出现。

联系我们

我们始终欢迎读者的反馈。

一般反馈:如果你对本书的任何方面有疑问,请在邮件主题中提及书名,并通过电子邮件联系 Packt,邮箱地址是 customercare@packtpub.com

勘误表:尽管我们已尽一切努力确保内容准确无误,但错误偶尔也会发生。如果你在本书中发现了错误,我们将不胜感激您向我们报告。请访问 www.packtpub.com/support/errata,选择您的书籍,点击 Errata Submission Form 链接并输入详细信息。

盗版:如果你在任何形式的互联网上发现我们作品的非法副本,我们将不胜感激您提供位置地址或网站名称。请通过链接copyright@packtpub.com与我们联系。

如果你对成为一名作者感兴趣:如果你有专业知识并且有意编写或贡献书籍,请访问 authors.packtpub.com

评论

请留下你的评论。阅读并使用本书后,请考虑在购买书籍的网站上留下您的评论。潜在读者可以看到并使用你的客观意见来做出购买决定,我们在 Packt 可以了解到你对我们产品的看法,而我们的作者也能看到你对他们书籍的反馈。谢谢!

欲了解更多有关 Packt 的信息,请访问 packtpub.com

第一章:NLP 基础

语言一直是人类进化的一部分。语言的发展使得人们和部落之间的交流变得更加顺畅。书面语言的演变,最初是洞穴壁画,后来变成字符,使信息能够被提炼、存储并从一代传递到另一代。有些人甚至会说,技术进步的曲线之所以呈现曲棍球棒形态,是因为信息的累积存储不断增长。随着这个存储的信息库越来越大,使用计算方法处理和提炼数据的需求变得更加迫切。在过去的十年里,图像和语音识别领域取得了许多进展。自然语言处理NLP)的进展则较为*期,尽管 NLP 的计算方法已经是几十年的研究领域。处理文本数据需要许多不同的构建模块,基于这些模块可以构建高级模型。这些构建模块本身可能相当具有挑战性和复杂性。本章和下一章将重点介绍这些构建模块以及通过简单模型可以解决的问题。

本章将重点介绍文本预处理的基础知识,并构建一个简单的垃圾邮件检测器。具体来说,我们将学习以下内容:

  • 典型的文本处理工作流

  • 数据收集与标注

  • 文本规范化,包括大小写规范化、文本分词、词干提取和词形还原

    • 建模已进行文本规范化的数据集

    • 文本向量化

    • 使用向量化文本的建模数据集

让我们从掌握大多数 NLP 模型所使用的文本处理工作流开始。

一个典型的文本处理工作流

要理解如何处理文本,首先要了解 NLP 的通用工作流。下图说明了基本步骤:

图 1.1:文本处理工作流的典型阶段

前述图表的前两步涉及收集标注数据。监督模型甚至半监督模型需要数据来操作。接下来的步骤通常是对数据进行规范化和特征化处理。模型处理文本数据时往往很困难,文本中存在许多隐藏的结构,需要经过处理和揭示。这两步正是聚焦于此。最后一步是基于处理过的输入数据构建模型。尽管 NLP 有一些独特的模型,本章仅使用一个简单的深度神经网络,更多关注规范化和向量化/特征化。通常,最后三个阶段会在一个循环中操作,尽管图表可能给人线性进行的印象。在工业界,额外的特性需要更多的开发工作和资源来维持运行。因此,特性必须带来价值。采用这种方法,我们将使用一个简单的模型来验证不同的规范化/向量化/特征化步骤。现在,让我们详细看看这些阶段。

数据收集和标记

机器学习ML)项目的第一步是获取数据集。在文本领域,幸运的是,可以找到大量的数据。一个常见的方法是使用诸如scrapy或 Beautiful Soup 之类的库从网络上爬取数据。然而,这些数据通常是未标记的,因此不能直接在监督模型中使用。尽管如此,这些数据非常有用。通过使用迁移学习,可以使用无监督或半监督的方法训练语言模型,并且可以进一步使用特定于手头任务的小训练数据集。在第三章中,使用 BiLSTMs、CRFs 和 Viterbi 解码进行命名实体识别(NER),我们将更深入地讨论迁移学习使用 BERT 嵌入的问题。

在标记步骤中,从数据收集步骤获取的文本数据会被标记为正确的类别。让我们举几个例子。如果任务是构建一个用于电子邮件的垃圾邮件分类器,那么前一步将涉及收集大量的电子邮件。这个标记步骤将是给每封电子邮件附上一个垃圾非垃圾的标签。另一个例子可能是在推特上进行情感检测。数据收集步骤将涉及收集一些推特。这一步将会给每条推特打上一个充当事实依据的标签。一个更复杂的例子可能涉及收集新闻文章,其中标签将是文章的摘要。另一个这种情况的例子可能是电子邮件自动回复功能。与垃圾邮件案例类似,需要收集一些带有回复的电子邮件。在这种情况下,标签将是一些短文本,用来*似回复。如果您在一个没有太多公开数据的特定领域工作,您可能需要自己完成这些步骤。

鉴于文本数据通常是可用的(除了像健康这样的特定领域),标记通常是最大的挑战。标记数据可能非常耗时或资源密集。最*已经有很多关注使用半监督方法来标记数据。在第七章中,多模态网络和使用 ResNets 和 Transformer 进行图像字幕生成,我们将介绍使用半监督方法和snorkel库来大规模标记数据的一些方法。

网络上有许多常用的数据集可以用来训练模型。通过迁移学习,这些通用数据集可以用来为机器学习模型提供初步训练,然后你可以使用少量特定领域的数据来微调模型。使用这些公开可用的数据集为我们带来了一些优势。首先,所有的数据收集工作已经完成。其次,标签已经做好。最后,使用这些数据集可以与当前最先进的技术进行结果比较;大多数论文会在其研究领域使用特定的数据集,并发布基准测试。例如,斯坦福问答数据集(简称SQuAD)通常作为问答模型的基准。这也是一个很好的训练数据源。

收集标注数据

在本书中,我们将依赖公开的数据集。适当的数据集将在各自的章节中指出,并附带下载说明。为了构建一个垃圾邮件检测系统,我们将使用加利福尼亚大学欧文分校提供的 SMS 垃圾邮件数据集。这个数据集可以通过下面提示框中的说明进行下载。每条短信都被标记为“SPAM”或“HAM”,其中“HAM”表示这不是垃圾邮件。

加利福尼亚大学欧文分校是机器学习数据集的一个重要来源。你可以访问archive.ics.uci.edu/ml/datasets.php查看他们提供的所有数据集。特别是对于自然语言处理(NLP),你可以在github.com/niderhoff/nlp-datasets找到一些公开的数据集。

在我们开始处理数据之前,需要先设置开发环境。让我们快速设置开发环境。

开发环境设置

在本章中,我们将使用 Google Colaboratory,简称 Colab,来编写代码。你可以使用你的 Google 账户,或者注册一个新账户。Google Colab 是免费的,不需要配置,同时也提供 GPU 访问。用户界面与 Jupyter 笔记本非常相似,因此应该很熟悉。要开始使用,请使用支持的浏览器访问colab.research.google.com。页面应类似于下面的截图:

图 1.2:Google Colab 网站

下一步是创建一个新的笔记本。这里有几种选择。第一个选项是在 Colab 中创建一个新的笔记本,并按照章节中的步骤输入代码。第二个选项是将本地磁盘中的笔记本上传到 Colab。也可以从 GitHub 拉取笔记本到 Colab,具体过程可以参考 Colab 网站。为了本章目的,书中的 GitHub 仓库中,chapter1-nlp-essentials 文件夹下有一个完整的名为 SMS_Spam_Detection.ipynb 的笔记本。请通过点击文件 | 上传笔记本将该笔记本上传到 Google Colab。笔记本的特定部分将在章节的提示框中提到。创建笔记本的详细步骤已在主要描述中列出。

点击左上角的文件菜单选项,然后点击新建笔记本。一个新的笔记本将在新的浏览器标签页中打开。点击左上角的笔记本名称,位于文件菜单选项正上方,将其编辑为 SMS_Spam_Detection。现在,开发环境已经设置完成,可以开始加载数据了。

首先,让我们编辑笔记本的第一行并导入 TensorFlow 2。请输入以下代码到第一个单元格并执行:

%tensorflow_version 2.x
import tensorflow as tf
import os
import io
tf.__version__ 

执行此单元格后输出应如下所示:

TensorFlow 2.x is selected.
'2.4.0' 

这确认了 TensorFlow 库的 2.4.0 版本已加载。前面代码块中突出显示的那行是 Google Colab 的魔法命令,指示它使用 TensorFlow 2+ 版本。下一步是下载数据文件并将其解压到 Colab 笔记本的云端位置。

加载数据的代码位于笔记本的下载数据部分。还要注意,本文写作时,TensorFlow 的发布版本为 2.4。

可以使用以下代码完成这一操作:

# Download the zip file
path_to_zip = tf.keras.utils.get_file("smsspamcollection.zip",
origin="https://archive.ics.uci.edu/ml/machine-learning-databases/00228/smsspamcollection.zip",
                  extract=True)
# Unzip the file into a folder
!unzip $path_to_zip -d data 

以下输出确认了数据已经下载并解压:

Archive:  /root/.keras/datasets/smsspamcollection.zip
  inflating: data/SMSSpamCollection  
  inflating: data/readme 

读取数据文件非常简单:

# Let's see if we read the data correctly
lines = io.open('data/SMSSpamCollection').read().strip().split('\n')
lines[0] 

最后一行代码显示了一条数据示例:

'ham\tGo until jurong point, crazy.. Available only in bugis n great world' 

这个示例标记为非垃圾邮件。下一步是将每一行分成两列——一列是消息的文本,另一列是标签。在分离这些标签的同时,我们还将标签转换为数值。由于我们要预测垃圾邮件,所以我们可以为垃圾邮件分配值 1。合法邮件将分配值 0

这部分的代码在笔记本的数据预处理部分。

请注意,以下代码为清晰起见,做了详细描述:

spam_dataset = []
for line in lines:
  label, text = line.split('\t')
  if label.strip() == 'spam':
    spam_dataset.append((1, text.strip()))
  else:
    spam_dataset.append(((0, text.strip())))
print(spam_dataset[0]) 
(0, 'Go until jurong point, crazy.. Available only in bugis n great world la e buffet... Cine there got amore wat...') 

现在,数据集已经准备好可以进一步在管道中处理了。不过,让我们稍作绕行,看看如何在 Google Colab 中配置 GPU 访问。

在 Google Colab 上启用 GPU

使用 Google Colab 的一个优势是可以访问免费的 GPU 来处理小任务。GPU 对于 NLP 模型的训练时间影响很大,尤其是使用递归神经网络RNNs)的模型。启用 GPU 访问的第一步是启动一个运行时,这可以通过在笔记本中执行一个命令来完成。然后,点击 Runtime 菜单选项并选择 Change Runtime 选项,如下图所示:

手机截图  描述自动生成

图 1.3:Colab 运行时设置菜单选项

接下来,将弹出一个对话框,如下图所示。展开 Hardware Accelerator 选项并选择 GPU

手机截图  描述自动生成

图 1.4:在 Colab 上启用 GPU

现在,你应该可以在 Colab 笔记本中使用 GPU 了!在 NLP 模型中,特别是使用 RNN 时,GPU 可以大幅减少训练时间,节省数分钟或数小时。

现在,让我们将注意力重新集中在已经加载并准备好进一步处理的数据上,以便用于模型。

文本归一化

文本归一化是一个预处理步骤,旨在提高文本质量,使其适合机器处理。文本归一化的四个主要步骤包括大小写归一化、标记化和停用词移除、词性POS)标注和词干提取。

大小写归一化适用于使用大写字母和小写字母的语言。所有基于拉丁字母或西里尔字母的语言(如俄语、蒙古语等)都使用大小写字母。其他偶尔使用这种方式的语言包括希腊语、亚美尼亚语、切罗基语和科普特语。在大小写归一化中,所有字母都会转换为相同的大小写。这在语义应用中非常有帮助。然而,在其他情况下,这可能会影响性能。在垃圾邮件示例中,垃圾邮件的消息通常包含更多的大写字母。

另一个常见的归一化步骤是移除文本中的标点符号。同样,这可能对当前问题有用,也可能没有用。在大多数情况下,这应该会产生良好的结果。然而,在某些情况下,比如垃圾邮件或语法模型,它可能会影响性能。垃圾邮件更有可能使用更多的感叹号或其他标点符号来进行强调。

这一部分的代码在笔记本的 Data Normalization 部分。

我们来构建一个基准模型,使用三个简单的特征:

  • 消息中的字符数量

  • 消息中的大写字母数量

  • 消息中的标点符号数量

为此,首先,我们将数据转换为一个pandas DataFrame:

import pandas as pd
df = pd.DataFrame(spam_dataset, columns=['Spam', 'Message']) 

接下来,让我们构建一些简单的函数,计算消息的长度、首字母大写字母的数量和标点符号的数量。Python 的正则表达式包 re 将用于实现这些功能:

import re
def message_length(x):
  # returns total number of characters
  return len(x)
def num_capitals(x):
 **_, count = re.subn(****r'[A-Z]'****,** **''****, x)** **# only works in english**
  return count
def num_punctuation(x):
  _, count = re.subn(r'\W', '', x)
  return count 

num_capitals()函数中,针对英语中的大写字母进行替换操作。这些替换的count值即为大写字母的数量。相同的技术也用于统计标点符号的数量。请注意,计数大写字母的方法仅适用于英语。

将会向 DataFrame 添加额外的特征列,然后将数据集拆分为测试集和训练集:

df['Capitals'] = df['Message'].apply(num_capitals)
df['Punctuation'] = df['Message'].apply(num_punctuation)
df['Length'] = df['Message'].apply(message_length)
df.describe() 

这应该生成以下输出:

图 1.5:初始垃圾邮件模型的基础数据集

以下代码可用于将数据集拆分为训练集和测试集,其中 80%的记录用于训练集,剩余的用于测试集。此外,还会从训练集和测试集中删除标签:

train=df.sample(frac=0.8,random_state=42)
test=df.drop(train.index)
x_train = train[['Length', 'Capitals', 'Punctuation']]
y_train = train[['Spam']]
x_test = test[['Length', 'Capitals', 'Punctuation']]
y_test = test[['Spam']] 

现在我们准备构建一个简单的分类器来使用这些数据。

建模规范化数据

请回忆建模是前面描述的文本处理流程的最后一步。在本章中,我们将使用一个非常简单的模型,因为我们的目标更多的是展示不同的基础 NLP 数据处理技术,而不是建模。在这里,我们想看看三个简单特征是否能帮助垃圾邮件的分类。随着更多特征的加入,通过相同的模型进行测试将帮助我们了解这些特征化是否有助于提高分类的准确性,或者是否会影响准确性。

工作簿的模型构建部分包含本节所示的代码。

定义了一个函数,允许构建具有不同输入和隐藏单元数量的模型:

# Basic 1-layer neural network model for evaluation
def make_model(input_dims=3, num_units=12):
  model = tf.keras.Sequential()
  # Adds a densely-connected layer with 12 units to the model:
  model.add(tf.keras.layers.Dense(num_units, 
                                  input_dim=input_dims,
                                  activation='relu'))
  # Add a sigmoid layer with a binary output unit:
  model.add(tf.keras.layers.Dense(1, activation='sigmoid'))
  model.compile(loss='binary_crossentropy', optimizer='adam', 
                metrics=['accuracy'])
  return model 

该模型使用二元交叉熵计算损失,并使用 Adam 优化器进行训练。由于这是一个二分类问题,关键指标是准确率。传递给函数的默认参数足够,因为只传递了三个特征。

我们可以仅使用三个特征来训练我们的简单基线模型,如下所示:

model = make_model()
model.fit(x_train, y_train, epochs=10, batch_size=10) 
Train on 4459 samples
Epoch 1/10
4459/4459 [==============================] - 1s 281us/sample - loss: 0.6062 - accuracy: 0.8141
Epoch 2/10
…
Epoch 10/10
4459/4459 [==============================] - 1s 145us/sample - loss: 0.1976 - accuracy: 0.9305 

这并不差,因为我们的三个简单特征帮助我们达到了 93%的准确率。快速检查显示测试集中有 592 条垃圾邮件,所有邮件总数为 4,459 条。因此,这个模型比一个非常简单的模型要好,后者会将所有邮件都预测为非垃圾邮件。那个模型的准确率是 87%。这个数字可能让人吃惊,但在数据中存在严重类别不*衡的分类问题中,这种情况是相当常见的。在训练集上的评估给出了大约 93.4%的准确率:

model.evaluate(x_test, y_test) 
1115/1115 [==============================] - 0s 94us/sample - loss: 0.1949 - accuracy: 0.9336
[0.19485870356516988, 0.9336323] 

请注意,您看到的实际性能可能会因为数据拆分和计算偏差而略有不同。可以通过绘制混淆矩阵来快速验证性能:

y_train_pred = model.predict_classes(x_train)
# confusion matrix
tf.math.confusion_matrix(tf.constant(y_train.Spam), 
                         y_train_pred)
<tf.Tensor: shape=(2, 2), dtype=int32, numpy=
array([[3771,   96],
      [ 186,  406]], dtype=int32)> 
预测为非垃圾邮件 预测为垃圾邮件
实际非垃圾邮件 3,771 96
实际垃圾邮件 186 406

这显示了 3,771 条常规消息中的 3,867 条被正确分类,而 592 条垃圾邮件中的 406 条被正确分类。同样,您可能会得到略有不同的结果。

为了测试特征的价值,尝试通过去除某个特征(如标点符号或多个大写字母)重新运行模型,以了解这些特征对模型的贡献。这留给读者作为练习。

分词

这个步骤将一段文本转换成一个标记的列表。如果输入的是句子,那么将句子分割成单词就是分词的一个例子。根据模型的不同,可以选择不同的粒度。在最低级别,每个字符都可以成为一个标记。在某些情况下,整句或段落可以作为一个标记:

一个徽标的特写,描述自动生成

图 1.6:分词一个句子

上面的图示展示了两种分词方法。一种方法是将句子拆分成单词。另一种方法是将句子拆分成单独的字符。然而,在一些语言中,如日语和普通话,这可能是一个复杂的任务。

日语中的分割

许多语言使用空格作为词的分隔符,这使得基于词的分词任务变得简单。然而,也有一些语言不使用任何标记或分隔符来区分单词。例如,日语和中文就是这样的语言。在这些语言中,这一任务被称为分割

具体来说,在日语中,主要有三种不同类型的字符:*假名汉字片假名。汉字源自中文字符,和中文一样,汉字有成千上万的字符。*假名用于语法元素和日语本土词汇。片假名主要用于外来词和人名。根据前面的字符,某个字符可能是现有单词的一部分,或者是新词的开始。这使得日语成为世界上最复杂的书写系统之一。复合词尤其难。考虑以下复合词,表示选举管理委员会

这可以通过两种不同的方式进行分词,除了将整个短语视为一个词。以下是使用 Sudachi 库进行分词的两个示例:

(选举 / 管理 / 委员会)

(选举 / 管理 / 委员会 / 会议)

专门用于日语分割或分词的常见库有 MeCab、Juman、Sudachi 和 Kuromoji。MeCab 被 Hugging Face、spaCy 和其他库使用。

本节展示的代码位于笔记本的分词和停用词移除部分。

幸运的是,大多数语言不像日语那样复杂,使用空格来分隔单词。在 Python 中,通过空格分割是非常简单的。让我们看一个例子:

Sentence = 'Go until Jurong point, crazy.. Available only in bugis n great world'
sentence.split() 

前面的分割操作的输出结果如下:

['Go',
 'until',
 'jurong',
 **'point,',
 'crazy..',**
 'Available',
 'only',
 'in',
 'bugis',
 'n',
 'great',
 'world'] 

上述输出中两个高亮的行显示,Python 中的简单方法会导致标点符号被包含在单词中,等等。因此,这一步是通过像 StanfordNLP 这样的库来完成的。使用 pip,我们可以在 Colab 笔记本中安装此包:

!pip install stanfordnlp 

StanfordNLP 包使用 PyTorch 底层支持,同时还使用了一些其他包。这些包和其他依赖项将会被安装。默认情况下,该包不会安装语言文件,这些文件需要手动下载。以下代码展示了这一过程:

Import stanfordnlp as snlp
en = snlp.download('en') 

英文文件大约为 235 MB。在下载前会显示提示,确认下载并选择存储位置:

图 1.7:下载英文模型的提示

Google Colab 在不活动时会回收运行时。这意味着如果你在不同的时间执行书中的命令,你可能需要重新执行每个命令,从头开始,包括下载和处理数据集、下载 StanfordNLP 的英文文件等等。一个本地的笔记本服务器通常会保持运行时的状态,但可能处理能力有限。对于本章中的简单示例,Google Colab 是一个不错的解决方案。对于书中后续的更高级示例,其中训练可能需要几个小时或几天,本地运行时或运行在云端的虚拟机VM)会更合适。

这个包提供了开箱即用的分词、词性标注和词形还原功能。为了开始分词,我们实例化一个管道并对一个示例文本进行分词,看看这个过程是如何工作的:

en = snlp.Pipeline(lang='en', processors='tokenize') 

lang 参数用于指示所需的英文管道。第二个参数 processors 指示管道中所需的处理类型。这个库还可以执行以下处理步骤:

  • pos 为每个词元标注一个词性标签(POS)。下一部分将提供更多关于词性标签的细节。

  • lemma,它可以将动词的不同形式转换为基本形式。例如,这将在本章后面的词干提取与词形还原部分中详细介绍。

  • depparse 执行句子中单词的依存句法分析。考虑以下示例句子:“Hari went to school。”Hari 被词性标注器解释为名词,并成为动词 went 的主词。schoolwent 的依赖词,因为它描述了动词的宾语。

目前,只需要进行文本分词,所以只使用分词器:

tokenized = en(sentence)
len(tokenized.sentences) 
2 

这表明分词器正确地将文本分成了两个句子。为了调查删除了哪些单词,可以使用以下代码:

for snt in tokenized.sentences:
  for word in snt.tokens:
    print(word.text)
  print("<End of Sentence>") 
Go
until
jurong
**point
,
crazy**
..
<End of Sentence>
Available
only
in
bugis
n
great
world
<End of Sentence> 

注意上述输出中标记的单词。标点符号被分离为独立的单词。文本被拆分为多个句子。这比仅使用空格进行拆分有所改进。在某些应用中,可能需要删除标点符号。这个问题将在下一节中讲解。

考虑上述日语示例。为了查看 StanfordNLP 在日语分词上的表现,可以使用以下代码片段:

jp = snlp.download('ja') 

这是第一步,涉及下载日语语言模型,类似于之前下载并安装的英语模型。接下来,将实例化一个日语处理管道,单词将会被处理:

jp = snlp.download('ja')
jp_line = jp("![](https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/adv-nlp-tf2x/img/B16252_01_001.png)") 

你可能记得,日文文本的内容是“选举管理委员会”。正确的分词应该生成三个单词,其中前两个单词各包含两个字符,最后一个单词包含三个字符:

for snt in jp_line.sentences:
  for word in snt.tokens:
    print(word.text) 
![](https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/adv-nlp-tf2x/img/B16252_01_002.png) 

这与预期输出匹配。StanfordNLP 支持 53 种语言,因此相同的代码可以用于分词任何受支持的语言。

回到垃圾邮件检测的例子,可以实现一个新特征,使用此分词功能来计算消息中的单词数。

这个字数特征是在笔记本中的 添加字数特征 部分实现的。

可能垃圾邮件的单词数量与常规邮件不同。第一步是定义一个方法来计算单词数:

en = snlp.Pipeline(lang='en')
def word_counts(x, pipeline=en):
  doc = pipeline(x)
  count = sum([len(sentence.tokens) for sentence in doc.sentences])
  return count 

接下来,使用训练和测试数据集的划分,添加一个字数特征列:

train['Words'] = train['Message'].apply(word_counts)
test['Words'] = test['Message'].apply(word_counts)
x_train = train[['Length', 'Punctuation', 'Capitals', 'Words']]
y_train = train[['Spam']]
x_test = test[['Length', 'Punctuation', 'Capitals' , 'Words']]
y_test = test[['Spam']]
model = make_model(input_dims=4) 

上述代码块中的最后一行创建了一个具有四个输入特征的新模型。

PyTorch 警告

当你在 StanfordNLP 库中执行函数时,可能会看到如下警告:

`/pytorch/aten/src/ATen/native/LegacyDefinitions.cpp:19: UserWarning: masked_fill_ received a mask with dtype torch.uint8, this behavior is now deprecated,please use a mask with dtype torch.bool instead.` 

在内部,StanfordNLP 使用了 PyTorch 库。这个警告是因为 StanfordNLP 使用了一个现在已经弃用的函数的旧版本。对于所有实际目的而言,这个警告可以忽略。预计 StanfordNLP 的维护者会更新他们的代码。

对分词数据进行建模

这个模型可以通过以下方式进行训练:

model.fit(x_train, y_train, epochs=10, batch_size=10)
Train on 4459 samples
Epoch 1/10
4459/4459 [==============================] - 1s 202us/sample - loss: 2.4261 - accuracy: 0.6961
...
Epoch 10/10
4459/4459 [==============================] - 1s 142us/sample - loss: 0.2061 - accuracy: 0.9312 

准确度的提高只是微乎其微。一种假设是,单词的数量没有太大用处。如果垃圾邮件的*均单词数比常规消息的单词数要小或大,那将是有用的。使用 pandas 可以快速验证这一点:

train.loc[train.Spam == 1].describe() 

手机截图 自动生成的描述

图 1.8:垃圾邮件特征的统计数据

让我们将上述结果与常规消息的统计数据进行比较:

train.loc[train.Spam == 0].describe() 

手机截图 自动生成的描述

图 1.9:常规消息特征的统计数据

一些有趣的模式可以迅速被发现。垃圾邮件通常与*均值的偏差较小。关注大写字母特征列。它显示正常邮件使用的首字母大写远少于垃圾邮件。在第 75 百分位,正常邮件有 3 个大写字母,而垃圾邮件有 21 个。*均而言,正常邮件有 4 个大写字母,而垃圾邮件有 15 个。在单词数量类别中,这种变化就不那么明显了。正常邮件*均有 17 个单词,而垃圾邮件有 29 个。在第 75 百分位,正常邮件有 22 个单词,而垃圾邮件有 35 个。这次快速检查表明,为什么添加单词特征没有那么有用。然而,仍然有几点需要考虑。首先,分词模型将标点符号分割成单词。理想情况下,这些标点符号应该从单词计数中移除,因为标点特征显示垃圾邮件使用了更多的标点符号字符。这将在词性标注部分中介绍。其次,语言中有一些常见的词汇通常会被排除。这被称为停用词移除,也是下一部分的重点。

停用词移除

停用词移除涉及去除常见的词汇,如冠词(the, an)和连接词(and, but)等。在信息检索或搜索的上下文中,这些词汇对于识别与查询匹配的文档或网页没有帮助。例如,考虑查询“Google 的总部在哪里?”。在这个查询中,is是一个停用词。无论是否包括is,查询结果都会是相似的。为了确定停用词,一种简单的方法是使用语法线索。

在英语中,冠词和连接词是通常可以移除的词类的例子。一种更强大的方法是考虑词汇在语料库、文档集或文本中的出现频率。最常出现的词汇可以被选为停用词列表的候选词。建议手动审查这个列表。有些情况下,某些词汇在文档集合中可能出现频繁,但仍然具有意义。如果所有文档来自特定领域或特定主题,这种情况可能会发生。考虑来自联邦储备的文档集。词汇economy在这种情况下可能会出现得非常频繁;然而,它不太可能作为停用词被移除。

在某些情况下,停用词可能实际上包含有用的信息。这可能适用于短语。例如,考虑“飞往巴黎的航班”这个片段。在这种情况下,to提供了有价值的信息,移除它可能会改变这个片段的含义。

回顾文本处理工作流的各个阶段。文本规范化后的步骤是向量化。这个步骤将在本章的文本向量化部分中详细讨论,但向量化的关键步骤是构建所有词元的词汇表或字典。通过移除停用词,可以减少词汇表的大小。在训练和评估模型时,去除停用词会减少需要执行的计算步骤。因此,去除停用词在计算速度和存储空间方面可以带来好处。现代 NLP 的进展使得停用词列表越来越小,随着更高效的编码方案和计算方法的发展,停用词列表也变得更加精简。让我们尝试并查看停用词对垃圾邮件问题的影响,以便更好地理解其有用性。

许多 NLP 包提供停用词的列表。可以在分词后从文本中移除这些停用词。分词之前是通过 StanfordNLP 库完成的。然而,这个库并没有提供停用词的列表。NLTK 和 spaCy 为多种语言提供停用词。在这个例子中,我们将使用一个叫做stopwordsiso的开源包。

笔记本中的停用词移除部分包含了这一部分的代码。

这个 Python 包从github.com/stopwords-iso/stopwords-iso的 stopwords-iso GitHub 项目中获取停用词列表。该包提供了 57 种语言的停用词。第一步是安装提供停用词列表的 Python 包。

以下命令将通过笔记本安装该包:

!pip install stopwordsiso 

可以使用以下命令检查支持的语言:

import stopwordsiso as stopwords
stopwords.langs() 

英语的停用词也可以检查,以了解其中的一些词汇:

sorted(stopwords.stopwords('en')) 
"'ll",
 "'tis",
 "'twas",
 "'ve",
 '10',
 '39',
 'a',
 "a's",
 'able',
 'ableabout',
 'about',
 'above',
 'abroad',
 'abst',
 'accordance',
 'according',
 'accordingly',
 'across',
 'act',
 'actually',
 'ad',
 'added',
... 

鉴于分词已经在前面的word_counts()方法中实现,可以更新该方法的实现,加入移除停用词的步骤。然而,所有的停用词都是小写字母。之前讨论过的大小写规范化对垃圾邮件检测来说是一个有用的特征。在这种情况下,词元需要转换为小写字母才能有效地去除它们:

en_sw = stopwords.stopwords('en')
def word_counts(x, pipeline=en):
  doc = pipeline(x)
  count = 0
  for sentence in doc.sentences:
    for token in sentence.tokens:
        if token.text.lower() not in en_sw:
          count += 1
  return count 

使用停用词的一个后果是,像“你什么时候骑自行车?”这样的消息只算作 3 个词。当我们查看这是否对单词长度的统计数据产生了影响时,出现了以下情况:

![A screenshot of a cell phone Description automatically generated 图 1.10:移除停用词后的垃圾邮件单词计数与移除停用词之前的单词计数相比,单词的*均数量从 29 减少到 18,几乎减少了 30%。第 25 百分位数从 26 变化为 14。最大值也从 49 减少到 33。对普通消息的影响更加显著:A screenshot of a cell phone  Description automatically generated

图 1.11:去除停用词后的常规消息的单词计数

将这些统计数据与去除停用词前的数据进行比较,*均单词数已经减半,接* 8 个。最大单词数也从 209 减少到了 147。常规消息的标准差与其均值差不多,表明常规消息中单词数量变化较大。现在,让我们看看这是否有助于我们训练一个模型并提高其准确度。

去除停用词后的数据建模

现在,去除停用词后的特征已经计算出来,可以将其加入模型中,看看它的影响:

train['Words'] = train['Message'].apply(word_counts)
test['Words'] = test['Message'].apply(word_counts)
x_train = train[['Length', 'Punctuation', 'Capitals', 'Words']]
y_train = train[['Spam']]
x_test = test[['Length', 'Punctuation', 'Capitals', 'Words']]
y_test = test[['Spam']]
model = make_model(input_dims=4)
model.fit(x_train, y_train, epochs=10, batch_size=10) 
Epoch 1/10
4459/4459 [==============================] - 2s 361us/sample - loss: 0.5186 - accuracy: 0.8652
Epoch 2/10
...
Epoch 9/10
4459/4459 [==============================] - 2s 355us/sample - loss: 0.1790 - accuracy: 0.9417
Epoch 10/10
4459/4459 [==============================] - 2s 361us/sample - loss: 0.1802 - accuracy: 0.9421 

这一准确度相较于之前的模型有了轻微的提升:

model.evaluate(x_test, y_test) 
1115/1115 [==============================] - 0s 74us/sample - loss: 0.1954 - accuracy: 0.9372
 [0.19537461110027382, 0.93721974] 

在自然语言处理(NLP)中,停用词去除曾经是标准做法。在现代应用中,停用词可能实际上会在某些使用场景中阻碍性能,而不是帮助提升。现在不排除停用词已经变得更加常见。根据你解决的问题,停用词去除可能会有帮助,也可能没有帮助。

请注意,StanfordNLP 会将像can't这样的词分开成can't。这表示将缩写词扩展为其组成部分,即cannot。这些缩写词可能出现在停用词列表中,也可能不在。实现更健壮的停用词检测器是留给读者的一个练习。

StanfordNLP 使用带有 双向长短期记忆BiLSTM)单元的监督 RNN。该架构使用词汇表通过词汇的向量化生成词嵌入。词汇的向量化和词嵌入的生成将在本章后面的向量化文本部分讲解。这种带有词嵌入的 BiLSTM 架构通常是 NLP 任务的常见起点。后续章节中将详细讲解并使用该架构。这种特定的分词架构被认为是截至本书写作时的最先进技术。在此之前,基于隐马尔可夫模型HMM)的模型曾经很流行。

根据所涉及的语言,基于正则表达式的分词也是一种方法。NLTK 库提供了基于正则表达式的 Penn Treebank 分词器,该分词器使用 sed 脚本实现。在后续章节中,将会解释其他分词或分段方案,如字节对编码BPE)和 WordPiece。

文本标准化的下一个任务是通过词性标注理解文本的结构。

词性标注

语言具有语法结构。在大多数语言中,单词可以主要分为动词、副词、名词和形容词。这个处理步骤的目标是对一段文本中的每个单词进行词性标注。请注意,这只有在处理词级标记时才有意义。常见的,Penn Treebank 词性标注器被包括 StanfordNLP 在内的库用来标注单词。根据惯例,词性标注通过在单词后加上一个斜杠后缀的代码来添加。例如,NNS 是复数名词的标记。如果遇到 goats 这个词,它会被表示为 goats/NNS。在 StanfordNLP 库中,使用的是 通用词性 (UPOS) 标记。以下是 UPOS 标记集的一部分。有关标准词性标记与 UPOS 标记映射的更多细节,请参见 universaldependencies.org/docs/tagset-conversion/en-penn-uposf.html。以下是最常见的标记表:

标记 类别 示例
ADJ 形容词:通常描述名词。比较级和最高级使用不同的标记。 很棒,漂亮
ADP 介词:用来修饰名词、代词或短语等对象;例如,“楼”。像英语这样的语言使用介词,而像印地语和日语等语言使用后置词。 上,里面
ADV 副词:修饰或限定形容词、动词或其他副词的词或短语。 大声地,经常
AUX 助动词:用来构成其他动词的语气、语态或时态。 将,会,可能
CCONJ 并列连词:连接两个短语、从句或句子。 和,但,那
INTJ 感叹词:表达惊讶、打断或突然的评论。 哦,呃,哈哈
NOUN 名词:指代人、地点或事物。 办公室,书
NUM 数词:表示数量。 六,九
DET 限定词:确定特定的名词,通常是单数形式。 一个,某个,那个
PART 助词:属于主要词性之外的词类。 到,不能
PRON 代词:替代其他名词,尤其是专有名词。 她,她的
PROPN 专有名词:指代特定的人员、地点或事物的名称。 甘地,美国
PUNCT 不同的标点符号。 ,? /
SCONJ 从属连词:连接独立从句和依赖从句。 因为,虽然
SYM 符号,包括货币符号、表情符号等。 $,#,% 😃
VERB 动词:表示动作或发生的事情。 去,做
X 其他:无法归类到其他类别的内容。 等等,4.(编号列表项)

最好的理解词性标注工作原理的方法是尝试一下:

本部分的代码位于笔记本的 POS 基础特征 部分。

en = snlp.Pipeline(lang='en')
txt = "Yo you around? A friend of mine's lookin."
pos = en(txt) 

上述代码实例化了一个英语处理流程并处理了一段示例文本。下一段代码是一个可重用的函数,用于打印带有词性标记的句子标记:

def print_pos(doc):
    text = ""
    for sentence in doc.sentences:
         for token in sentence.tokens:
            text += token.words[0].text + "/" + \
                    token.words[0].upos + " "
         text += "\n"
    return text 

这个方法可以用来调查前面示例句子的标记:

print(print_pos(pos)) 
Yo/PRON you/PRON around/ADV ?/PUNCT 
A/DET friend/NOUN of/ADP mine/PRON 's/PART lookin/NOUN ./PUNCT 

大部分词性标记是合理的,尽管可能存在一些不准确之处。例如,单词lookin被错误地标记为名词。无论是 StanfordNLP,还是其他包中的模型,都不可能做到完美。这是我们在构建使用这些特征的模型时必须考虑到的。可以通过这些词性标记构建几种不同的特征。首先,我们可以更新word_counts()方法,排除标点符号在单词计数中的影响。当前方法在计算单词时未考虑标点符号。还可以创建其他特征,分析消息中不同类型语法元素的比例。需要注意的是,到目前为止,所有的特征都是基于文本结构,而非内容本身。关于内容特征的工作将在本书的后续部分详细讲解。

下一步,让我们更新word_counts()方法,添加一个特征,用于显示消息中符号和标点符号的百分比——假设垃圾邮件消息可能使用更多标点符号和符号。还可以构建关于不同语法元素类型的其他特征。这些留给你自己实现。我们更新后的word_counts()方法如下:

en_sw = stopwords.stopwords('en')
def word_counts_v3(x, pipeline=en):
  doc = pipeline(x)
  totals = 0.
  count = 0.
  non_word = 0.
  for sentence in doc.sentences:
    totals += len(sentence.tokens)  # (1)
    for token in sentence.tokens:
        if token.text.lower() not in en_sw:
          if token.words[0].upos not in ['PUNCT', 'SYM']:
            count += 1.
          else:
            non_word += 1.
  non_word = non_word / totals
  return pd.Series([count, non_word], index=['Words_NoPunct', 'Punct']) 

这个函数与之前的稍有不同。由于每一行的消息需要执行多个计算,这些操作被组合起来并返回一个带有列标签的Series对象。这样可以像下面这样与主数据框合并:

train_tmp = train['Message'].apply(word_counts_v3)
train = pd.concat([train, train_tmp], axis=1) 

在测试集上可以执行类似的过程:

test_tmp = test['Message'].apply(word_counts_v3)
test = pd.concat([test, test_tmp], axis=1) 

对训练集中垃圾邮件和非垃圾邮件消息的统计进行快速检查,首先是非垃圾邮件消息:

train.loc[train['Spam']==0].describe() 

手机截图  自动生成的描述

图 1.12:使用词性标记后的常规消息统计

然后是垃圾邮件消息:

train.loc[train['Spam']==1].describe() 

手机截图  自动生成的描述

图 1.13:使用词性标记后的垃圾邮件消息统计

一般来说,在去除停用词后,单词计数进一步减少。此外,新的Punct特征计算了消息中标点符号令牌相对于总令牌的比例。现在我们可以用这些数据构建一个模型。

使用词性标记进行数据建模

将这些特征插入模型后,得到以下结果:

x_train = train[['Length', 'Punctuation', 'Capitals', 'Words_NoPunct', 'Punct']]
y_train = train[['Spam']]
x_test = test[['Length', 'Punctuation', 'Capitals' , 'Words_NoPunct', 'Punct']]
y_test = test[['Spam']]
model = make_model(input_dims=5)
# model = make_model(input_dims=3)
model.fit(x_train, y_train, epochs=10, batch_size=10) 
Train on 4459 samples
Epoch 1/10
4459/4459 [==============================] - 1s 236us/sample - loss: 3.1958 - accuracy: 0.6028
Epoch 2/10
...
Epoch 10/10
4459/4459 [==============================] - 1s 139us/sample - loss: 0.1788 - **accuracy: 0.9466** 

准确率略有提高,目前达到了 94.66%。测试结果显示,它似乎成立:

model.evaluate(x_test, y_test) 
1115/1115 [==============================] - 0s 91us/sample - loss: 0.2076 - accuracy: **0.9426**
[0.20764057086989485, 0.9426009] 

文本规范化的最后一步是词干提取和词形还原。虽然我们不会使用此方法为垃圾邮件模型构建任何特征,但在其他情况下它非常有用。

词干提取和词形还原

在某些语言中,相同的词可以根据其用法呈现略有不同的形式。以单词depend为例,以下都是有效的depend形式:dependsdependingdependeddependent。这些变化通常与时态相关。在一些语言(如印地语)中,动词可能会根据不同的性别而变化。另一个例子是同一词汇的派生词,如sympathysympatheticsympathizesympathizer。这些变化在其他语言中可能有不同的形式。在俄语中,专有名词会根据用法变化其形式。假设有一篇文档讨论伦敦(Лондон)。短语in London(в Лондоне)中的London拼写方式与from London(из Лондона)中的London不同。这些London拼写上的变化可能会在将输入与文档中的某些部分或单词匹配时产生问题。

在处理和标记文本以构建语料库中出现的单词词汇表时,识别根词的能力可以减少词汇表的大小,同时提高匹配的准确性。在前面的俄语示例中,如果所有形式的单词都在标记化后归一化为统一的表示形式,那么London的任何形式都可以与其他形式匹配。这个归一化过程被称为词干提取或词形还原。

词干提取和词形还原在方法和复杂性上有所不同,但服务于相同的目标。词干提取是一种更简单的启发式基于规则的方法,它去除单词的词缀。最著名的词干提取器叫做 Porter 词干提取器,由 Martin Porter 于 1980 年发布。官方网站是tartarus.org/martin/PorterStemmer/,这里链接了用不同语言实现的算法的各种版本。

这个词干提取器仅适用于英语,并有规则,包括移除名词复数形式的* s ,以及移除像 -ed -ing *的词尾。考虑以下句子:

“词干提取的目的是减少词汇量并帮助理解形态学过程。这有助于人们理解单词的形态,并减少语料库的大小。”

使用 Porter 算法进行词干提取后,这个句子将被简化为以下内容:

“Stem is aim at reduce vocabulari and aid understand of morpholog process . Thi help peopl understand the morpholog of word and reduc size of corpu .”

请注意,morphologyunderstandreduce的不同形式都被标记化为相同的形式。

词元化以更复杂的方式处理这个任务,利用词汇和单词的形态学分析。在语言学研究中,形态素是小于或等于一个单词的单位。当形态素本身就是一个单词时,它被称为根或自由形态素。相反,每个单词都可以分解为一个或多个形态素。形态素的研究被称为形态学。利用这些形态学信息,可以在分词后返回单词的根形式。这个单词的基础或字典形式被称为词元,因此这个过程被称为词元化。StanfordNLP 将词元化作为处理的一部分。

笔记本中的词元化部分展示了这里的代码。

这是一个简单的代码片段,用于处理前述句子并解析它们:

text = "Stemming is aimed at reducing vocabulary and aid understanding of morphological processes. This helps people understand the morphology of words and reduce size of corpus."
lemma = en(text) 

处理后,我们可以通过迭代令牌来获取每个单词的词元。如下代码片段所示,单词的词元以每个令牌内单词的.lemma属性呈现。为了简化代码,这里做了一个简化假设,即每个令牌只有一个单词。

每个单词的词性(POS)也会被打印出来,帮助我们理解过程是如何进行的。以下输出中一些关键字已被突出显示:

lemmas = ""
for sentence in lemma.sentences:
        for token in sentence.tokens:
            lemmas += token.words[0].lemma +"/" + \
                    token.words[0].upos + " "
        lemmas += "\n"
print(lemmas) 
stem/NOUN be/AUX aim/VERB at/SCONJ **reduce/VERB** vocabulary/NOUN and/CCONJ aid/NOUN **understanding/NOUN** of/ADP **morphological/ADJ** process/NOUN ./PUNCT 
this/PRON help/VERB people/NOUN **understand/VERB** the/DET **morphology/NOUN** of/ADP word/NOUN and/CCONJ **reduce/VERB** size/NOUN of/ADP corpus/ADJ ./PUNCT 

将此输出与之前的 Porter 词干提取器的输出进行对比。一个立即可以注意到的点是,词元是真正的单词,而不像 Porter 词干提取器中那样是碎片。在reduce的情况下,两句话中的用法都是动词形式,因此词元选择是一致的。重点关注前面输出中的understandunderstanding。如 POS 标签所示,它们分别是两种不同的形式。因此,它们没有还原为相同的词元。这与 Porter 词干提取器不同。对于morphologymorphological,也可以观察到相同的行为。这是一种非常复杂的行为。

现在文本标准化已完成,我们可以开始文本的向量化处理。

文本向量化

在迄今为止构建短信垃圾信息检测模型的过程中,仅考虑了基于词汇或语法特征计数或分布的聚合特征。至今为止,消息中的实际单词尚未被使用。使用消息的文本内容有几个挑战。首先,文本的长度是任意的。与图像数据相比,我们知道每张图像都有固定的宽度和高度。即使图像集合包含不同尺寸的图像,通过使用各种压缩机制,图像可以被调整为统一大小且信息损失最小。而在自然语言处理(NLP)中,这比计算机视觉要复杂得多。处理这个问题的一种常见方法是截断文本。我们将在本书中的不同示例中看到处理可变长度文本的各种方法。

第二个问题是如何用数值或特征来表示单词。在计算机视觉中,最小的单位是像素。每个像素都有一组数值表示颜色或强度。在文本中,最小的单位可能是单词。将字符的 Unicode 值聚合并不能传达或体现单词的意义。实际上,这些字符编码并不包含关于字符的任何信息,比如它的普遍性、它是辅音还是元音,等等。然而,*均化图像某一部分的像素值可能是对该区域的合理*似。这可能代表从远距离看到该区域时的样子。那么,一个核心问题就是构建单词的数值表示。向量化是将单词转换为数值向量的过程,目的是体现单词所包含的信息。根据不同的向量化技术,这些向量可能有额外的特性,使得它们可以与其他单词进行比较,正如本章后面词向量部分所展示的那样。

最简单的向量化方法是使用单词计数。第二种方法更为复杂,起源于信息检索,称为 TF-IDF。第三种方法相对较新,发表于 2013 年,使用 RNN 生成嵌入或单词向量。此方法称为 Word2Vec。截止到目前为止,最先进的方法是 BERT,它于 2018 年最后一个季度发布。本章将讨论前三种方法,BERT 将在第三章中详细讨论,命名实体识别(NER)与 BiLSTMs、CRFs 及维特比解码

基于计数的向量化

基于计数的向量化思想其实很简单。语料库中每个唯一出现的单词都会被分配一个词汇表中的列。每个文档(对应垃圾邮件示例中的个别信息)都会分配一行。文档中出现的单词的计数会填写在对应文档和单词的单元格中。如果有n个独特的文档包含m个独特的单词,那么就会得到一个nm列的矩阵。假设有如下的语料库:

corpus = [
          "I like fruits. Fruits like bananas",
          "I love bananas but eat an apple",
          "An apple a day keeps the doctor away"
] 

这个语料库中有三个文档。scikit-learnsklearn)库提供了进行基于计数的向量化的方法。

模拟基于计数的向量化

在 Google Colab 中,该库应该已经安装。如果在你的 Python 环境中没有安装,可以通过笔记本以如下方式安装:

!pip install sklearn 

CountVectorizer 类提供了一个内置的分词器,用于分隔长度大于或等于两个字符的标记。这个类提供了多种选项,包括自定义分词器、停用词列表、将字符转换为小写字母后再进行分词的选项,以及一个二进制模式,将每个正向计数转换为 1。默认选项对于英语语料库来说提供了一个合理的选择:

from sklearn.feature_extraction.text import CountVectorizer
vectorizer = CountVectorizer()
X = vectorizer.fit_transform(corpus)
vectorizer.get_feature_names() 
['an',
 'apple',
 'away',
 'bananas',
 'but',
 'day',
 'doctor',
 'eat',
 'fruits',
 'keeps',
 'like',
 'love',
 'the'] 

在前面的代码中,模型被拟合到语料库上。最后一行输出的是作为列使用的标记。完整的矩阵可以如下所示:

X.toarray() 
array([[0, 0, 0, 1, 0, 0, 0, 0, 2, 0, 2, 0, 0],
       [1, 1, 0, 1, 1, 0, 0, 1, 0, 0, 0, 1, 0],
       [1, 1, 1, 0, 0, 1, 1, 0, 0, 1, 0, 0, 1]]) 

该过程已经将像 "I like fruits. Fruits like bananas" 这样的句子转化为一个向量(0, 0, 0, 1, 0, 0, 0, 2, 0, 2, 0, 0)。这是无关上下文向量化的一个例子。无关上下文是指文档中单词的顺序在生成向量时没有任何影响。这仅仅是对文档中单词出现的次数进行计数。因此,具有多重含义的单词可能会被归为一个类别,例如 bank。它可以指代河岸边的地方或存钱的地方。然而,它确实提供了一种比较文档并推导相似度的方法。可以计算两个文档之间的余弦相似度或距离,以查看哪些文档与其他文档相似:

from sklearn.metrics.pairwise import cosine_similarity
cosine_similarity(X.toarray())
array([[1\.        , 0.13608276, 0\.        ],
       [0.13608276, 1\.        , 0.3086067 ],
       [0\.        , 0.3086067 , 1\.        ]]) 

这表明第一句和第二句的相似度分数为 0.136(0 到 1 的范围)。第一句和第三句没有任何相似之处。第二句和第三句的相似度分数为 0.308——这是这个集合中的最高值。这个技术的另一个应用场景是检查给定关键词的文档相似度。假设查询是 apple and bananas。第一步是计算这个查询的向量,然后计算它与语料库中各文档的余弦相似度分数:

query = vectorizer.transform(["apple and bananas"])
cosine_similarity(X, query)
array([[0.23570226],
       [0.57735027],
       [0.26726124]]) 

这表明该查询与语料库中的第二个句子匹配最佳。第三个句子排在第二,第一个句子排名最低。通过几行代码,基本的搜索引擎已经实现,并且具备了服务查询的逻辑!在大规模的情况下,这是一个非常难的问题,因为网页爬虫中的单词或列数将超过 30 亿。每个网页都会作为一行表示,因此也需要数十亿行。计算余弦相似度并在毫秒级别内响应在线查询,同时保持矩阵内容的更新,是一项巨大的工作。

这个相对简单的向量化方案的下一步是考虑每个单词的信息内容,在构建这个矩阵时加以考虑。

词频-逆文档频率(TF-IDF)

在创建文档的向量表示时,只考虑了词的出现情况——并未考虑词的重要性。如果处理的文档语料库是关于水果的食谱,那么像苹果覆盆子清洗这样的词可能会频繁出现。术语频率TF)表示某个词或标记在给定文档中出现的频率。这正是我们在前一节所做的。在一个关于水果和烹饪的文档集里,像苹果这样的词可能不太特定,难以帮助识别食谱。然而,像薄脆饼tuile)这样的词在该语境中可能比较少见。因此,它可能比像覆盆子这样的词更快地帮助缩小食谱搜索范围。顺便说一句,不妨上网搜索一下覆盆子薄脆饼的食谱。如果一个词比较稀有,我们希望给予它更高的权重,因为它可能包含比常见词更多的信息。术语可以通过它出现的文档数量的倒数来提升权重。因此,出现在大量文档中的词将得到较小的分数,而出现在较少文档中的词则得分较高。这被称为逆文档频率IDF)。

从数学角度看,文档中每个术语的分数可以按如下方式计算:

这里,t表示词或术语,d表示特定的文档。

通常,通过文档中所有标记的总数来规范化一个术语在文档中的 TF 值。

IDF 的定义如下:

这里,N代表语料库中文档的总数,n[t]代表术语出现的文档数量。分母中加 1 可以避免除以零的错误。幸运的是,sklearn提供了计算 TF-IDF 的方法。

笔记本中的TF-IDF 向量化部分包含此部分的代码。

让我们将前一部分中的计数转换为它们的 TF-IDF 等效值:

import pandas as pd
from sklearn.feature_extraction.text import TfidfTransformer
transformer = TfidfTransformer(smooth_idf=False)
tfidf = transformer.fit_transform(X.toarray())
pd.DataFrame(tfidf.toarray(), 
             columns=vectorizer.get_feature_names()) 

这会产生如下输出:

A screenshot of a cell phone  Description automatically generated

这应该能帮助理解 TF-IDF 是如何计算的。即使只有三句话和一个非常有限的词汇表,每行中的许多列都是 0。这种向量化产生了稀疏表示

现在,这可以应用于检测垃圾信息的问题。到目前为止,每条信息的特征已经基于一些聚合统计值进行计算,并添加到pandas DataFrame 中。接下来,信息的内容将被标记化,并转换为一组列。每个词或标记的 TF-IDF 分数将被计算为数组中每条信息的值。使用sklearn,这实际上是非常简单的,代码如下:

from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn. pre-processing import LabelEncoder
tfidf = TfidfVectorizer(binary=True)
X = tfidf.fit_transform(train['Message']).astype('float32')
X_test = tfidf.transform(test['Message']).astype('float32')
X.shape 
(4459, 7741) 

第二个参数显示已唯一标识了 7,741 个标记。这些是稍后将在模型中使用的特征列。请注意,向量化器是通过二进制标志创建的。这意味着即使一个标记在一条消息中出现多次,它也只会被计为一次。接下来的行在训练数据集上训练 TF-IDF 模型。然后,它会根据从训练集中学到的 TF-IDF 分数转换测试集中的单词。让我们只用这些 TF-IDF 特征训练一个模型。

使用 TF-IDF 特征建模

使用这些 TF-IDF 特征,让我们训练一个模型并看看它的表现:

_, cols = X.shape
model2 = make_model(cols)  # to match tf-idf dimensions
y_train = train[['Spam']]
y_test = test[['Spam']]
model2.fit(X.toarray(), y_train, epochs=10, batch_size=10) 
Train on 4459 samples
Epoch 1/10
4459/4459 [==============================] - 2s 380us/sample - loss: 0.3505 - accuracy: 0.8903
...
Epoch 10/10
4459/4459 [==============================] - 1s 323us/sample - loss: 0.0027 - accuracy: 1.0000 

哇—我们能够正确分类每一个!说实话,模型可能存在过拟合问题,因此应该应用一些正则化。测试集给出的结果如下:

model2.evaluate(X_test.toarray(), y_test) 
1115/1115 [==============================] - 0s 134us/sample - loss: 0.0581 - accuracy: 0.9839
[0.05813191874545786, 0.9838565] 

98.39%的准确率迄今为止是我们在任何模型中获得的最佳结果。查看混淆矩阵,可以明显看出这个模型确实表现得非常好:

y_test_pred = model2.predict_classes(X_test.toarray())
tf.math.confusion_matrix(tf.constant(y_test.Spam), 
                         y_test_pred)
<tf.Tensor: shape=(2, 2), dtype=int32, numpy=
array([[958,   2],
       [ 16, 139]], dtype=int32)> 

仅有 2 条常规消息被分类为垃圾邮件,而只有 16 条垃圾邮件被分类为非垃圾邮件。这确实是一个非常好的模型。请注意,这个数据集中包含了印尼语(或巴哈萨语)单词以及英语单词。巴哈萨语使用拉丁字母。这个模型没有使用大量的预训练和对语言、词汇、语法的了解,却能够在手头的任务中表现得非常合理。

然而,这种模型完全忽略了单词之间的关系。它将文档中的单词视为集合中的无序项。实际上,有更好的模型通过向量化方式保留了一些单词之间的关系,这将在下一节中进行探讨。

词向量

在前面的例子中,使用了一个行向量来表示文档。这被作为分类模型的特征来预测垃圾邮件标签。然而,从单词之间的关系中并不能可靠地提取到信息。在自然语言处理(NLP)中,很多研究集中在无监督地学习单词或表示。这被称为表示学习。这种方法的输出是单词在某个向量空间中的表示,单词可以被视为嵌入在该空间中。因此,这些词向量也被称为嵌入向量。

词向量算法背后的核心假设是,相邻出现的单词之间是相关的。为了理解这一点,考虑两个单词,bake(烤)和oven(烤箱)。假设一个包含五个单词的句子片段,其中一个单词是上述两个单词之一,另一个单词出现在其中的概率是多少?你猜测其概率可能会很高,这是正确的。现在假设单词被映射到某个二维空间中。在这个空间里,这两个单词应该彼此靠得更*,可能会远离像astronomy(天文学)和tractor(拖拉机)这样的单词。

学习这些单词的嵌入表示的任务可以被看作是在一个巨大的多维空间中调整单词,使得相似的单词彼此接*,不相似的单词彼此远离。

一种革命性的做法称为 Word2Vec。这一算法由 Tomas Mikolov 及其 Google 团队的合作伙伴于 2013 年发布。该方法通常生成 50-300 维的密集向量(虽然也有更大维度的向量),其中大多数值为非零值。相比之下,在我们之前简单的垃圾邮件示例中,TF-IDF 模型有 7,741 维。原始论文中提出了两种算法:连续词袋模型连续 Skip-Gram 模型。在语义任务和整体表现上,Skip-Gram 模型在其发布时是最先进的。因此,带有负采样的连续 Skip-Gram 模型已经成为 Word2Vec 的代名词。该模型的直觉非常简单。

考虑这个来自食谱的句子片段:“烤至饼干表面金黄”。假设一个单词与其附*的单词相关联,可以从这个片段中选取一个单词,并训练一个分类器来预测其周围的单词:

一个 logo 的特写图像,描述自动生成

图 1.14:以饼干为中心的 5 个单词窗口

以一个包含五个单词的窗口为例,窗口中心的单词用于预测其前后各两个单词。在前面的图中,片段是直到饼干变金黄,焦点是单词饼干。假设词汇表中有 10,000 个单词,可以训练一个网络,根据一对单词预测二元决策。训练目标是,网络对像(饼干, 金黄)这样的词对预测true,而对像(饼干, 袋鼠)这样的词对预测false。这种方法被称为Skip-Gram 负采样SGNS),它大大减少了大词汇量训练所需的时间。与上一节中的单层神经网络模型非常相似,可以通过一对多的输出层训练一个模型。sigmoid 激活函数将被更改为softmax函数。如果隐藏层有 300 个单元,那么其维度将是 10,000 x 300,即每个单词都会有一组权重。训练的目标是学习这些权重。实际上,这些权重会在训练完成后成为该单词的嵌入表示。

隐藏层中单元数的选择是一个超参数,可以根据特定应用进行调整。300 个单元通常被采用,因为它可以通过 Google 新闻数据集上的预训练嵌入获得。最后,误差被计算为所有负样本和正样本中词对的分类交叉熵之和。

这个模型的优点在于它不需要任何监督式的训练数据。运行的句子可以用来提供正面示例。为了让模型有效学习,提供负面样本也非常重要。词汇是根据它们在训练语料库中的出现概率随机抽样,并作为负面示例输入。

为了理解 Word2Vec 嵌入是如何工作的,我们来下载一组预训练的嵌入。

以下部分显示的代码可以在笔记本的词向量部分找到。

使用 Word2Vec 嵌入的预训练模型

由于我们只对实验预训练模型感兴趣,可以使用 Gensim 库及其预训练嵌入。Gensim 应该已经安装在 Google Colab 中,可以通过以下方式安装:

!pip install gensim 

在进行必要的导入之后,可以下载并加载预训练嵌入。请注意,这些嵌入的大小大约是 1.6 GB,因此可能需要很长时间才能加载(你也可能会遇到一些内存问题):

from gensim.models.word2vec import Word2Vec
import gensim.downloader as api
model_w2v = api.load("word2vec-google-news-300") 

你可能会遇到的另一个问题是,如果下载等待时间过长,Colab 会话可能会过期。此时可能是切换到本地笔记本的好时机,这对后续章节也会有所帮助。现在,我们准备好检查相似词汇了:

model_w2v.most_similar("cookies",topn=10) 
[('cookie', 0.745154082775116),
 ('oatmeal_raisin_cookies', 0.6887780427932739),
 ('oatmeal_cookies', 0.662139892578125),
 ('cookie_dough_ice_cream', 0.6520504951477051),
 ('brownies', 0.6479344964027405),
 ('homemade_cookies', 0.6476464867591858),
 ('gingerbread_cookies', 0.6461867690086365),
 ('Cookies', 0.6341644525527954),
 ('cookies_cupcakes', 0.6275068521499634),
 ('cupcakes', 0.6258294582366943)] 

这非常不错。让我们看看这个模型在词类比任务中的表现:

model_w2v.doesnt_match(["USA","Canada","India","Tokyo"]) 
'Tokyo' 

该模型能够猜测,与其他所有国家名称相比,东京是唯一的异常词,因为它是一个城市。现在,让我们在这些词向量上试试一个非常著名的数学例子:

king = model_w2v['king']
man = model_w2v['man']
woman = model_w2v['woman']
queen = king - man + woman  
model_w2v.similar_by_vector(queen) 
[('king', 0.8449392318725586),
 ('queen', 0.7300517559051514),
 ('monarch', 0.6454660892486572),
 ('princess', 0.6156251430511475),
 ('crown_prince', 0.5818676948547363),
 ('prince', 0.5777117609977722),
 ('kings', 0.5613663792610168),
 ('sultan', 0.5376776456832886),
 ('Queen_Consort', 0.5344247817993164),
 ('queens', 0.5289887189865112)] 

假设King作为输入提供给方程式,简单地从输入和输出中筛选,就能得到Queen作为最优结果。可以尝试使用这些嵌入进行短信垃圾分类。然而,后续章节将介绍如何使用 GloVe 嵌入和 BERT 嵌入进行情感分析。

像前面提到的预训练模型可以用于将文档向量化。使用这些嵌入,可以训练特定用途的模型。在后续章节中,我们将详细讨论生成上下文嵌入的新方法,例如 BERT。

摘要

在本章中,我们讲解了自然语言处理的基础,包括收集和标注训练数据、分词、停用词去除、大小写标准化、词性标注、词干提取和词形还原。还涉及了日语和俄语等语言中的一些特殊性。通过使用从这些方法中衍生出来的多种特征,我们训练了一个模型来分类垃圾信息,该信息包含英语和印尼语的混合词汇。这让我们得到了一个 94%准确率的模型。

然而,使用消息内容时的主要挑战在于如何定义一种方法,将词语表示为向量,以便进行计算。我们从一种简单的基于计数的向量化方案开始,然后过渡到更复杂的 TF-IDF 方法,前者生成了稀疏向量。这个 TF-IDF 方法在垃圾邮件检测任务中实现了 98%以上的准确率。

最后,我们看到了一个生成密集词向量的现代方法,叫做 Word2Vec。这个方法虽然已经有几年历史,但在许多生产应用中依然非常相关。一旦生成了词向量,它们可以被缓存用于推理,这使得使用这些词向量的机器学习模型在运行时具有相对较低的延迟。

我们使用了一个非常基础的深度学习模型来解决短信垃圾分类任务。就像卷积神经网络CNNs)是计算机视觉中的主流架构一样,循环神经网络RNNs),特别是基于长短时记忆网络LSTM)和双向 LSTMBiLSTM)的网络,是构建自然语言处理(NLP)模型时最常用的架构。在下一章中,我们将介绍 LSTM 的结构,并使用 BiLSTM 构建情感分析模型。这些模型将在未来的章节中以创造性的方式广泛应用于解决不同的 NLP 问题。

第二章:使用 BiLSTM 理解自然语言中的情感

自然语言理解NLU)是自然语言处理NLP)的一个重要子领域。在过去十年中,随着亚马逊 Alexa 和苹果 Siri 等聊天机器人取得戏剧性成功,这一领域的兴趣再次激增。本章将介绍 NLU 的广泛领域及其主要应用。

一种特定的模型架构叫做递归神经网络RNN),其中包含称为长短期记忆LSTM)单元的特殊单元,旨在使自然语言理解任务更加容易。NLP 中的 LSTM 类似于计算机视觉中的卷积块。我们将以两个例子来构建可以理解自然语言的模型。第一个例子是理解电影评论的情感,这将是本章的重点。另一个例子是自然语言理解的基本构建块之一,命名实体识别NER)。这将是下一章的主要内容。

构建能够理解情感的模型需要使用双向 LSTMBiLSTM),以及第一章中介绍的自然语言处理基础技术。具体来说,本章将涵盖以下内容:

  • 自然语言理解(NLU)及其应用概述

  • 使用 LSTM 和 BiLSTM 的 RNN 和 BiRNN 的概述

  • 使用 LSTM 和 BiLSTM 分析电影评论的情感

  • 使用tf.data和 TensorFlow Datasets 包来管理数据加载

  • 优化数据加载的性能,以有效利用 CPU 和 GPU

我们将从自然语言理解(NLU)的快速概述开始,然后直接进入 BiLSTM。

自然语言理解

NLU 使得能够处理非结构化文本并提取有意义和可操作的关键信息。让计算机理解文本句子是一个非常困难的挑战。NLU 的一个方面是理解句子的意义。在理解句子后,情感分析就变得可能了。另一个有用的应用是将句子分类到某个主题。这种主题分类还可以帮助消除实体的歧义。考虑以下句子:“CNN 有助于提高物体识别的准确性。”如果不了解这个句子是关于机器学习的,可能会对实体 CNN 做出错误推断。它可能被解释为新闻组织,而不是计算机视觉中的深度学习架构。本章稍后将使用特定的 RNN 架构——BiLSTM,构建一个情感分析模型。

NLU 的另一个方面是从自由文本中提取信息或命令。这些文本可以来自将语音转换成文本,例如将说话内容转换为 Amazon Echo 设备所说的文本。语音识别技术的快速进展现已使得语音被视为等同于文本。从文本中提取命令,比如对象和要执行的动作,能够通过语音命令控制设备。考虑示例句子 "调低音量。" 在这里,"音量" 是对象,"调低" 是动作。从文本中提取这些内容后,可以将这些动作与可用的动作列表匹配并执行。这种能力使得先进的 人机交互 (HCI) 成为可能,从而能够通过语音命令控制家用电器。NER 用于检测句子中的关键词。

这种技术在构建表单填写或槽填充聊天机器人时非常有用。命名实体识别(NER)也是其他自然语言理解(NLU)技术的基础,这些技术执行诸如关系提取等任务。考虑句子 "Sundar Pichai 是 Google 的 CEO。" 在这个句子中,"Sundar Pichai" 和 "Google" 这两个实体之间的关系是什么?正确答案是 CEO。这是关系提取的一个例子,NER 用于识别句子中的实体。下一章的重点是使用特定架构的 NER,这在这一领域中已证明非常有效。

情感分析和 NER 模型的常见构建块是双向 RNN 模型。下一节将描述 BiLSTM,它是使用 LSTM 单元的双向 RNN,然后我们将用它来构建情感分析模型。

双向 LSTM – BiLSTM

LSTM 是递归神经网络(RNN)的一种类型。RNN 是为处理序列并学习其结构而构建的。RNN 通过使用在处理序列中的前一个项目后生成的输出,与当前项目一起生成下一个输出。

从数学上讲,可以这样表达:

这个方程表示,要计算在时间 t 时刻的输出,t-1 时刻的输出会作为输入,与同一时间步的输入数据 x[t] 一起使用。除此之外,一组参数或学习得到的权重,表示为 ,也用于计算输出。训练一个 RNN 的目标是学习这些权重 。这种特定形式的 RNN 是独特的。在之前的例子中,我们没有使用一个批次的输出去决定未来批次的输出。虽然我们主要关注 RNN 在语言上的应用,其中一个句子被建模为一个接一个出现的词序列,但 RNN 也可以应用于构建通用的时间序列模型。

RNN 构建块

前一部分概述了递归函数的基本数学直觉,这是 RNN 构建块的简化版本。图 2.1 表示了几个时间步骤,并添加了细节,展示了用于基本 RNN 构建块或单元计算的不同权重。

图 2.1:RNN 解构

基本单元如左图所示。特定时间或序列步骤 t 的输入向量与一个权重向量相乘,图中表示为 U,以生成中间部分的激活。该架构的关键部分是该激活部分中的循环。前一个步骤的输出与一个权重向量相乘,图中用 V 表示,并加到激活中。该激活可以与另一个权重向量相乘,表示为 W,以产生该步骤的输出,如顶部所示。就序列或时间步骤而言,该网络可以展开。这种展开是虚拟的。然而,它在图的右侧得到了表示。从数学角度看,时间步骤 t 的激活可以表示为:

同一步骤的输出可以这样计算:

RNN 的数学已经简化,以便提供对 RNN 的直观理解。

从结构上看,网络非常简单,因为它是一个单一单元。为了利用和学习通过的输入结构,权重向量 UVW 在时间步骤之间共享。该网络没有像全连接或卷积网络那样的层。然而,由于它在时间步骤上展开,可以认为它有与输入序列中步骤数量相等的层。要构建深度 RNN,还需要满足其他标准。稍后在本节中会详细讨论。这个网络使用反向传播和随机梯度下降技术进行训练。这里需要注意的关键是,反向传播是在序列或时间步骤中发生的,而不是通过层进行反向传播。

拥有这种结构使得可以处理任意长度的序列。然而,随着序列长度的增加,会出现一些挑战:

  • 梯度消失与爆炸:随着序列长度的增加,反向传播的梯度会越来越小。这会导致网络训练缓慢或完全不学习。随着序列长度的增加,这一效果会更加明显。在上一章中,我们构建了一个少量层的网络。在这里,10 个单词的句子相当于 10 层网络。10 毫秒的 1 分钟音频样本将生成 6000 个步骤!相反,如果输出值增加,梯度也可能爆炸。管理梯度消失的最简单方法是使用 ReLU。管理梯度爆炸的技术称为梯度裁剪。这种技术会在梯度的大小超过阈值时将其裁剪。这样可以防止梯度过大或爆炸。

  • 无法管理长期依赖性:假设在一个 11 个单词的句子中,第 3 个单词信息量很大。以下是一个简单的例子:“我认为足球是全世界最受欢迎的运动。”当处理到句子的结尾时,序列中前面单词的贡献会越来越小,因为它们与向量V反复相乘,正如上面所示。

  • 两种特定的 RNN 单元设计缓解了这些问题长短期记忆LSTM)和门控循环单元GRU)。这些将在接下来描述。然而,请注意,TensorFlow 默认提供了这两种类型的单元的实现。因此,使用这些单元类型构建 RNN 几乎是小菜一碟。

长短期记忆(LSTM)网络

LSTM 网络是在 1997 年提出的,并经过许多研究人员的改进和推广。它们今天广泛用于各种任务,并取得了惊人的成果。

LSTM 有四个主要部分:

  • 单元值或网络的记忆,也称为单元,存储着累积的知识

  • 输入门,控制输入在计算新单元值时的使用量

  • 输出门,决定单元值中有多少用于输出

  • 遗忘门,决定当前单元值有多少用于更新单元值

如下图所示:

图 2.2:LSTM 单元(来源:Madsen,《在 RNN 中可视化记忆》,Distill,2019 年)

训练 RNN 是一个非常复杂的过程,充满了许多挑战。现代工具如 TensorFlow 很好地管理了复杂性,并大大减少了痛苦。然而,训练 RNN 仍然是一个具有挑战性的任务,尤其是在没有 GPU 支持的情况下。但一旦做对了,所带来的回报是值得的,尤其是在 NLP 领域。

在简要介绍 GRU 之后,我们将继续讨论 LSTM,讲解 BiLSTM,并构建一个情感分类模型。

门控循环单元(GRU)

GRU 是另一种流行且较新的 RNN 单元类型。它们是在 2014 年发明的。相比 LSTM,它们更加简洁:

图 2.3:门控循环单元(GRU)架构

与 LSTM 相比,它的门控较少。输入门和遗忘门合并成一个更新门。部分内部单元状态和隐藏状态也合并在一起。这种复杂度的降低使得训练变得更加容易。它在语音和声音领域已表现出优秀的效果。然而,在神经机器翻译任务中,LSTM 表现出了更优的性能。本章将重点讲解如何使用 LSTM。在我们讨论 BiLSTM 之前,先让我们通过 LSTM 来解决一个情感分类问题。然后,我们将尝试使用 BiLSTM 改进该模型。

使用 LSTM 进行情感分类

情感分类是自然语言处理(NLP)中常被引用的应用场景。通过使用来自推文的情感分析特征来预测股票价格波动的模型已经显示出有希望的结果。推文情感也用于确定客户对品牌的看法。另一个应用场景是处理电影或电子商务网站上的产品用户评论。为了展示 LSTM 的实际应用,我们将使用 IMDb 的电影评论数据集。该数据集在 ACL 2011 会议上发布,论文题目为学习用于情感分析的词向量。该数据集包含 25,000 个训练集样本和另外 25,000 个测试集样本。

这个示例中的代码将使用本地笔记本。第十章代码的安装与设置说明,提供了如何设置开发环境的详细说明。简而言之,您需要 Python 3.7.5 及以下库才能开始:

  • pandas 1.0.1

  • NumPy 1.18.1

  • TensorFlow 2.4 和 tensorflow_datasets 3.2.1

  • Jupyter 笔记本

我们将按照第一章NLP 基础中概述的整体过程来进行。我们从加载所需的数据开始。

加载数据

在上一章中,我们使用pandas库下载并加载了数据。这个方法将整个数据集加载到内存中。然而,有时数据量可能非常大,或者分布在多个文件中。在这种情况下,数据可能会过大,无法加载并需要大量的预处理。使文本数据准备好供模型使用,至少需要进行标准化和向量化处理。通常,这些处理需要在 TensorFlow 图之外使用 Python 函数来完成。这可能会导致代码的可重现性问题。此外,这还会给生产环境中的数据管道带来问题,因为不同的依赖阶段被分开执行时,出现故障的概率更高。

TensorFlow 通过tf.data包提供了解决数据加载、转换和批处理的方案。此外,通过tensorflow_datasets包提供了多个可供下载的数据集。我们将结合这些工具来下载 IMDb 数据,并在训练 LSTM 模型之前进行分词、编码和向量化处理。

所有情感评论示例的代码都可以在 GitHub 仓库中的 chapter2-nlu-sentiment-analysis-bilstm 文件夹下找到。代码在名为 IMDB Sentiment analysis.ipynb 的 IPython notebook 中。

第一步是安装适当的包并下载数据集:

!pip install tensorflow_datasets
import tensorflow as tf
import tensorflow_datasets as tfds
import numpy as np 

tfds 包提供了许多不同领域的数据集,例如图像、音频、视频、文本、摘要等。要查看可用的数据集:

", ".join(tfds.list_builders()) 
'abstract_reasoning, aeslc, aflw2k3d, amazon_us_reviews, arc, bair_robot_pushing_small, beans, big_patent, bigearthnet, billsum, binarized_mnist, binary_alpha_digits, c4, caltech101, caltech_birds2010, caltech_birds2011, cars196, cassava, cats_vs_dogs, celeb_a, celeb_a_hq, cfq, chexpert, cifar10, cifar100, cifar10_1, cifar10_corrupted, citrus_leaves, cityscapes, civil_comments, clevr, cmaterdb, cnn_dailymail, coco, coil100, colorectal_histology, colorectal_histology_large, cos_e, curated_breast_imaging_ddsm, cycle_gan, deep_weeds, definite_pronoun_resolution, diabetic_retinopathy_detection, div2k, dmlab, downsampled_imagenet, dsprites, dtd, duke_ultrasound, dummy_dataset_shared_generator, dummy_mnist, emnist, eraser_multi_rc, esnli, eurosat, fashion_mnist, flic, flores, food101, gap, gigaword, glue, groove, higgs, horses_or_humans, i_naturalist2017, image_label_folder, imagenet2012, imagenet2012_corrupted, imagenet_resized, imagenette, imagewang, imdb_reviews, iris, kitti, kmnist, lfw, librispeech, librispeech_lm, libritts, lm1b, lost_and_found, lsun, malaria, math_dataset, mnist, mnist_corrupted, movie_rationales, moving_mnist, multi_news, multi_nli, multi_nli_mismatch, natural_questions, newsroom, nsynth, omniglot, open_images_v4, opinosis, oxford_flowers102, oxford_iiit_pet, para_crawl, patch_camelyon, pet_finder, places365_small, plant_leaves, plant_village, plantae_k, qa4mre, quickdraw_bitmap, reddit_tifu, resisc45, rock_paper_scissors, rock_you, scan, scene_parse150, scicite, scientific_papers, shapes3d, smallnorb, snli, so2sat, speech_commands, squad, stanford_dogs, stanford_online_products, starcraft_video, sun397, super_glue, svhn_cropped, ted_hrlr_translate, ted_multi_translate, tf_flowers, the300w_lp, tiny_shakespeare, titanic, trivia_qa, uc_merced, ucf101, vgg_face2, visual_domain_decathlon, voc, wider_face, wikihow, wikipedia, wmt14_translate, wmt15_translate, wmt16_translate, wmt17_translate, wmt18_translate, wmt19_translate, wmt_t2t_translate, wmt_translate, xnli, xsum, yelp_polarity_reviews' 

这是一个包含 155 个数据集的列表。有关数据集的详细信息可以在目录页面 www.tensorflow.org/datasets/catalog/overview 查看。

IMDb 数据提供了三个分割——训练集、测试集和无监督集。训练集和测试集各有 25,000 行数据,每行有两列。第一列是评论文本,第二列是标签。 "0" 表示带有负面情绪的评论,而 "1" 表示带有正面情绪的评论。以下代码加载训练集和测试集数据:

imdb_train, ds_info = tfds.load(name="imdb_reviews", split="train", 
                               with_info=True, as_supervised=True)
imdb_test = tfds.load(name="imdb_reviews", split="test", 
                      as_supervised=True) 

请注意,由于数据正在下载,此命令可能需要一些时间才能执行。ds_info 包含有关数据集的信息。当提供 with_info 参数时,它会返回该信息。让我们来看一下 ds_info 中包含的信息:

print(ds_info) 
tfds.core.DatasetInfo(
    name='imdb_reviews',
    version=1.0.0,
    description='Large Movie Review Dataset.
This is a dataset for binary sentiment classification containing substantially more data than previous benchmark datasets. We provide a set of 25,000 highly polar movie reviews for training, and 25,000 for testing. There is additional unlabeled data for use as well.',
    homepage='http://ai.stanford.edu/~amaas/data/sentiment/',
    features=FeaturesDict({
        'label': ClassLabel(shape=(), dtype=tf.int64, num_classes=2),
        'text': Text(shape=(), dtype=tf.string),
    }),
    total_num_examples=100000,
    splits={
        'test': 25000,
        'train': 25000,
        'unsupervised': 50000,
    },
    supervised_keys=('text', 'label'),
    citation="""@InProceedings{maas-EtAl:2011:ACL-HLT2011,
      author    = {Maas, Andrew L.  and  Daly, Raymond E.  and  Pham, Peter T.  and  Huang, Dan  and  Ng, Andrew Y.  and  Potts, Christopher},
      title     = {Learning Word Vectors for Sentiment Analysis},
      booktitle = {Proceedings of the 49th Annual Meeting of the Association for Computational Linguistics: Human Language Technologies},
      month     = {June},
      year      = {2011},
      address   = {Portland, Oregon, USA},
      publisher = {Association for Computational Linguistics},
      pages     = {142--150},
      url       = {http://www.aclweb.org/anthology/P11-1015}
    }""",
    redistribution_info=,
) 

我们可以看到,在监督模式下,textlabel 两个键是可用的。使用 as_supervised 参数是将数据集加载为一组值元组的关键。如果没有指定此参数,数据将作为字典键加载并提供。在数据有多个输入的情况下,这可能是首选。为了了解已加载的数据:

for example, label in imdb_train.take(1):
    print(example, '\n', label) 
tf.Tensor(b"This was an absolutely terrible movie. Don't be lured in by Christopher Walken or Michael Ironside. Both are great actors, but this must simply be their worst role in history. Even their great acting could not redeem this movie's ridiculous storyline. This movie is an early nineties US propaganda piece. The most pathetic scenes were those when the Columbian rebels were making their cases for revolutions. Maria Conchita Alonso appeared phony, and her pseudo-love affair with Walken was nothing but a pathetic emotional plug in a movie that was devoid of any real meaning. I am disappointed that there are movies like this, ruining actor's like Christopher Walken's good name. I could barely sit through it.", shape=(), dtype=string)
tf.Tensor(0, shape=(), dtype=int64) 

上面的评论是负面评论的示例。接下来的步骤是对评论进行分词和向量化处理。

标准化与向量化

第一章NLP 基础中,我们讨论了多种标准化方法。在这里,我们只会将文本分词为单词并构建词汇表,然后使用该词汇表对单词进行编码。这是一种简化的方法。构建额外特征的方法有很多,可以使用第一章中讨论的技术,如 POS 标注,构建多个特征,但这留给读者作为练习。在本示例中,我们的目标是使用相同的特征集在 RNN 上进行 LSTM 训练,然后使用相同的特征集在改进的 BiLSTM 模型上进行训练。

在向量化之前,需要构建一个包含数据中所有令牌的词汇表。分词将文本中的单词拆分为单个令牌。所有令牌的集合构成了词汇表。

文本的标准化,例如转换为小写字母等,是在此分词步骤中执行的。tfds 提供了一组用于文本的特征构建器,位于 tfds.features.text 包中。首先,需要创建一个包含训练数据中所有单词的集合:

tokenizer = tfds.features.text.Tokenizer()
vocabulary_set = set()
MAX_TOKENS = 0
for example, label in imdb_train:
  some_tokens = tokenizer.tokenize(example.numpy())
  if MAX_TOKENS < len(some_tokens):
        MAX_TOKENS = len(some_tokens)
  vocabulary_set.update(some_tokens) 

通过遍历训练示例,对每个评论进行分词,将评论中的单词添加到一个集合中。将它们添加到集合中以获取唯一的单词。注意,令牌或单词并未转换为小写。这意味着词汇表的大小会稍微大一些。使用此词汇表,可以创建一个编码器。TokenTextEncodertfds 中提供的三种现成编码器之一。注意如何将令牌列表转换为集合,以确保词汇表中只保留唯一的令牌。用于生成词汇表的分词器被传递进来,这样每次调用编码字符串时都能使用相同的分词方案。此编码器期望分词器对象提供 tokenize()join() 方法。如果您想使用 StanfordNLP 或前一章节中讨论的其他分词器,只需将 StanfordNLP 接口封装在自定义对象中,并实现方法来将文本拆分为令牌并将令牌合并回字符串:

imdb_encoder = tfds.features.text.TokenTextEncoder(vocabulary_set, 
                                              tokenizer=tokenizer)
vocab_size = imdb_encoder.vocab_size
print(vocab_size, MAX_TOKENS) 
93931 2525 

词汇表包含 93,931 个令牌。最长的评论有 2,525 个令牌。真是一个冗长的评论!评论的长度会有所不同。LSTM 期望输入的是长度相等的序列。填充和截断操作会使评论具有相同的长度。在执行此操作之前,我们先测试编码器是否正常工作:

for example, label in imdb_train.take(1):
    print(example)
    encoded = imdb_encoder.encode(example.numpy())
    print(imdb_encoder.decode(encoded)) 
tf.Tensor(b"This was an absolutely terrible movie. Don't be lured in by Christopher Walken or Michael Ironside. Both are great actors, but this must simply be their worst role in history. Even their great acting could not redeem this movie's ridiculous storyline. This movie is an early nineties US propaganda piece. The most pathetic scenes were those when the Columbian rebels were making their cases for revolutions. Maria Conchita Alonso appeared phony, and her pseudo-love affair with Walken was nothing but a pathetic emotional plug in a movie that was devoid of any real meaning. I am disappointed that there are movies like this, ruining actor's like Christopher Walken's good name. I could barely sit through it.", shape=(), dtype=string)
This was an absolutely terrible movie Don t be lured in by Christopher Walken or Michael Ironside Both are great actors but this must simply be their worst role in history Even their great acting could not redeem this movie s ridiculous storyline This movie is an early nineties US propaganda piece The most pathetic scenes were those when the Columbian rebels were making their cases for revolutions Maria Conchita Alonso appeared phony and her pseudo love affair with Walken was nothing but a pathetic emotional plug in a movie that was devoid of any real meaning I am disappointed that there are movies like this ruining actor s like Christopher Walken s good name I could barely sit through it 

注意,当从编码表示重新构建这些评论时,标点符号会被移除。

编码器提供的一个便利功能是将词汇表持久化到磁盘。这使得词汇表和分布的计算可以仅执行一次,以供生产用例使用。即使在开发过程中,每次运行或重启笔记本之前,计算词汇表也可能是一项资源密集型的任务。将词汇表和编码器保存到磁盘,可以在完成词汇表构建步骤后,从任何地方继续进行编码和模型构建。要保存编码器,可以使用以下命令:

imdb_encoder.save_to_file("reviews_vocab") 

要从文件加载编码器并进行测试,可以使用以下命令:

enc = tfds.features.text.TokenTextEncoder.load_from_file("reviews_vocab")
enc.decode(enc.encode("Good case. Excellent value.")) 
'Good case Excellent value' 

对每次小批量的行进行分词和编码。TensorFlow 提供了执行这些操作的机制,允许在大数据集上批量执行,可以对数据集进行打乱并分批加载。这使得可以在训练过程中加载非常大的数据集,而不会因内存不足而中断。为了实现这一点,需要定义一个函数,该函数对数据行进行转换。注意,可以将多个转换链式执行。也可以在定义这些转换时使用 Python 函数。处理上述评论时,需要执行以下步骤:

  • 分词:评论需要被分词为单词。

  • 编码:这些单词需要使用词汇表映射为整数。

  • 填充:评论的长度可以不同,但 LSTM 期望向量的长度相同。因此,选择了一个固定长度。短于这个长度的评论会用特定的词汇索引填充,通常是0,在 TensorFlow 中是这样处理的。长于这个长度的评论则会被截断。幸运的是,TensorFlow 提供了这样一个开箱即用的功能。

以下函数执行了此操作:

from tensorflow.keras.preprocessing import sequence
def encode_pad_transform(sample):
    encoded = imdb_encoder.encode(sample.numpy())
    pad = sequence.pad_sequences([encoded], padding='post', 
                                 maxlen=150)
    return np.array(pad[0], dtype=np.int64)  
def encode_tf_fn(sample, label):
    encoded = tf.py_function(encode_pad_transform, 
                                       inp=[sample], 
                                       Tout=(tf.int64))
    encoded.set_shape([None])
    label.set_shape([])
    return encoded, label 

encode_tf_fn由数据集 API 调用,一次处理一个样本。这意味着它处理的是一个包含评论及其标签的元组。此函数会调用另一个函数encode_pad_transform,该函数包装在tf.py_function调用中,执行实际的转换操作。在这个函数中,首先进行分词,然后是编码,最后是填充和截断。选择了最大长度 150 个标记或词进行填充/截断序列。此第二个函数可以使用任何 Python 逻辑。例如,可以使用 StanfordNLP 包来执行词性标注,或者像上一章那样去除停用词。在这个示例中,我们尽量保持简单。

填充是一个重要的步骤,因为 TensorFlow 中的不同层不能处理宽度不同的张量。宽度不同的张量被称为不规则张量(ragged tensors)。目前正在进行相关的工作来支持不规则张量,并且该支持正在不断改进。然而,在 TensorFlow 中不规则张量的支持并不是普遍存在的。因此,在本书中避免使用不规则张量。

转换数据是非常简单的。让我们在一个小样本数据上试试这段代码:

subset = imdb_train.take(10)
tst = subset.map(encode_tf_fn)
for review, label in tst.take(1):
    print(review, label)
    print(imdb_encoder.decode(review)) 
tf.Tensor(
[40205  9679 51728 91747 21013  7623  6550 40338 18966 36012 64846 80722
 81643 29176 14002 73549 52960 40359 49248 62585 75017 67425 18181  2673
 44509 18966 87701 56336 29928 64846 41917 49779 87701 62585 58974 82970
  1902  2754 18181  7623  2615  7927 67321 40205  7623 43621 51728 91375
 41135 71762 29392 58948 76770 15030 74878 86231 49390 69836 18353 84093
 76562 47559 49390 48352 87701 62200 13462 80285 76037 75121  1766 59655
  6569 13077 40768 86201 28257 76220 87157 29176  9679 65053 67425 93397
 74878 67053 61304 64846 93397  7623 18560  9679 50741 44024 79648  7470
 28203 13192 47453  6386 18560 79892 49248  7158 91321 18181 88633 13929
  2615 91321 81643 29176  2615 65285 63778 13192 82970 28143 14618 44449
 39028     0     0     0     0     0     0     0     0     0     0     0
     0     0     0     0     0     0     0     0     0     0     0     0
     0     0     0     0     0     0], shape=(150,), dtype=int64) tf.Tensor(0, shape=(), dtype=int64)
This was an absolutely terrible movie Don t be lured in by Christopher Walken or Michael Ironside Both are great actors but this must simply be their worst role in history Even their great acting could not redeem this movie s ridiculous storyline This movie is an early nineties US propaganda piece The most pathetic scenes were those when the Columbian rebels were making their cases for revolutions Maria Conchita Alonso appeared phony and her pseudo love affair with Walken was nothing but a pathetic emotional plug in a movie that was devoid of any real meaning I am disappointed that there are movies like this ruining actor s like Christopher Walken s good name I could barely sit through it 

注意输出第一部分中编码张量末尾的"0"。这是填充到 150 个词后的结果。

可以这样在整个数据集上运行这个 map 操作:

encoded_train = imdb_train.map(encode_tf_fn)
encoded_test = imdb_test.map(encode_tf_fn) 

这应该会非常快速地执行。当训练循环执行时,映射将在此时执行。在tf.data.DataSet类中,还提供了其他有用的命令,imdb_trainimdb_test都是该类的实例,这些命令包括filter()shuffle()batch()filter()可以从数据集中删除某些类型的数据。它可以用来过滤掉长度过长或过短的评论,或者将正面和负面的例子分开,从而构建一个更*衡的数据集。第二个方法会在训练的每个时期之间打乱数据。最后一个方法则用于将数据批处理以进行训练。请注意,如果这些方法的应用顺序不同,将会产生不同的数据集。

使用tf.data进行性能优化:

图 2.4:通过顺序执行 map 函数所需时间的示意例子(来源:Better Performance with the tf.data API,访问链接:tensorflow.org/guide/data_performance)

如上图所示,多个操作共同影响一个 epoch 中的整体训练时间。上面这个示例图展示了需要打开文件(如最上面一行所示)、读取数据(如下一行所示)、对读取的数据执行映射转换,然后才能进行训练。由于这些步骤是按顺序进行的,这可能会使整体训练时间变长。相反,映射步骤可以并行执行,这将使得整体执行时间变短。CPU 用于预取、批处理和转换数据,而 GPU 则用于训练计算和诸如梯度计算和更新权重等操作。通过对上述 map 函数调用做一个小的修改,可以启用此功能:

encoded_train = imdb_train.map(encode_tf_fn,
       num_parallel_calls=tf.data.experimental.AUTOTUNE)
encoded_test = imdb_test.map(encode_tf_fn,
       num_parallel_calls=tf.data.experimental.AUTOTUNE) 

传递额外的参数使 TensorFlow 能够使用多个子进程来执行转换操作。

这可以带来如下面所示的加速效果:

图 2.5: 由于映射的并行化而减少训练时间的示例(来源:在 tensorflow.org/guide/data_performance 上的 tf.data API 提升性能)

虽然我们已经对评论文本进行了标准化和编码,但还没有将其转换为词向量或嵌入。这一步骤将在下一步模型训练中完成。因此,我们现在可以开始构建一个基本的 LSTM 模型。

带嵌入的 LSTM 模型

TensorFlow 和 Keras 使得实例化一个基于 LSTM 的模型变得非常简单。实际上,添加一个 LSTM 层只需一行代码。最简单的形式如下所示:

tf.keras.layers.LSTM(rnn_units) 

在这里,rnn_units 参数决定了在一个层中连接多少个 LSTM。还有许多其他参数可以配置,但默认值相当合理。TensorFlow 文档很好地详细说明了这些选项及其可能的取值,并附有示例。然而,评论文本标记不能直接输入到 LSTM 层中。它们需要通过嵌入方案进行向量化。有几种不同的方法可以使用。第一种方法是在模型训练时学习这些嵌入。这是我们将要使用的方法,因为它是最简单的方式。如果你所拥有的文本数据是某个特定领域的,如医学转录,这也是最好的方法。这个方法需要大量的数据来训练,以便嵌入能够学习到单词之间的正确关系。第二种方法是使用预训练的嵌入,如 Word2vec 或 GloVe,正如上一章所示,使用它们来向量化文本。这种方法在通用文本模型中表现良好,甚至可以适应特定领域。转移学习是 第四章 的重点内容,使用 BERT 进行转移学习

回到学习嵌入,TensorFlow 提供了一个嵌入层,可以在 LSTM 层之前添加。这个层有几个选项,文档也有很好的说明。为了完成二分类模型,剩下的就是一个最终的全连接层,该层有一个单元用于分类。一个可以构建具有一些可配置参数的模型的工具函数可以这样配置:

def build_model_lstm(vocab_size, embedding_dim, rnn_units, batch_size):
  model = tf.keras.Sequential([
    tf.keras.layers.Embedding(vocab_size, embedding_dim, 
                              mask_zero=True,
                              batch_input_shape=[batch_size, None]),
    tf.keras.layers.LSTM(rnn_units),
    tf.keras.layers.Dense(1, activation='sigmoid')
  ])
  return model 

这个函数暴露了一些可配置的参数,以便尝试不同的架构。除了这些参数之外,批次大小是另一个重要的参数。可以按照以下方式配置:

vocab_size = imdb_encoder.vocab_size 
# The embedding dimension
embedding_dim = 64
# Number of RNN units
rnn_units = 64
# batch size
BATCH_SIZE=100 

除了词汇表大小外,所有其他参数都可以进行调整,以查看对模型性能的影响。在设置好这些配置后,可以构建模型:

model = build_model_lstm(
  vocab_size = vocab_size,
  embedding_dim=embedding_dim,
  rnn_units=rnn_units,
  batch_size=BATCH_SIZE)
model.summary() 
Model: "sequential_3"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
embedding_3 (Embedding)      (100, None, 64)           6011584   
_________________________________________________________________
lstm_3 (LSTM)                (100, 64)                 33024     
_________________________________________________________________
dense_5 (Dense)              (100, 1)                  65        
=================================================================
Total params: 6,044,673
Trainable params: 6,044,673
Non-trainable params: 0
_________________________________________________________________ 

这样一个小模型有超过 600 万个可训练参数。很容易检查嵌入层的大小。词汇表中的总标记数为 93,931。每个标记都由一个 64 维的嵌入表示,提供了 93,931 X 64 = 6,011,584 个参数。

现在,这个模型已经准备好进行编译,指定损失函数、优化器和评估指标。在这种情况下,由于只有两个标签,所以使用二元交叉熵作为损失函数。Adam 优化器是一个非常好的选择,具有很好的默认设置。由于我们正在进行二分类,准确率、精确度和召回率是我们希望在训练过程中跟踪的指标。然后,需要对数据集进行批处理,并开始训练:

model.compile(loss='binary_crossentropy', 
             optimizer='adam', 
             metrics=['accuracy', 'Precision', 'Recall'])
encoded_train_batched = encoded_train.batch(BATCH_SIZE)
model.fit(encoded_train_batched, epochs=10) 
Epoch 1/10
250/250 [==============================] - 23s 93ms/step - loss: 0.4311 - accuracy: 0.7920 - Precision: 0.7677 - Recall: 0.8376
Epoch 2/10
250/250 [==============================] - 21s 83ms/step - loss: 0.1768 - accuracy: 0.9353 - Precision: 0.9355 - Recall: 0.9351
…
Epoch 10/10
250/250 [==============================] - 21s 85ms/step - loss: 0.0066 - accuracy: 0.9986 - Precision: 0.9986 - Recall: 0.9985 

这是一个非常好的结果!让我们将其与测试集进行比较:

model.evaluate(encoded_test.batch(BATCH_SIZE)) 
 250/Unknown - 20s 80ms/step - loss: 0.8682 - accuracy: 0.8063 - Precision: 0.7488 - Recall: 0.9219 

训练集和测试集之间的性能差异表明模型发生了过拟合。管理过拟合的一种方法是在 LSTM 层后引入一个 dropout 层。这个部分留给你作为练习。

上述模型是在 NVIDIA RTX 2070 GPU 上训练的。使用仅 CPU 时,每个 epoch 的时间可能会更长。

现在,让我们看看 BiLSTM 在这个任务中的表现如何。

BiLSTM 模型

在 TensorFlow 中构建 BiLSTM 很容易。所需要的只是对模型定义进行一行的修改。在 build_model_lstm() 函数中,添加 LSTM 层的那一行需要修改。修改后的新函数如下所示,修改的部分已突出显示:

def build_model_bilstm(vocab_size, embedding_dim, rnn_units, batch_size):
  model = tf.keras.Sequential([
    tf.keras.layers.Embedding(vocab_size, embedding_dim, 
                              mask_zero=True,
                              batch_input_shape=[batch_size, None]),
    **tf.keras.layers.Bidirectional(tf.keras.layers.LSTM(rnn_units)),**
    tf.keras.layers.Dense(1, activation='sigmoid')
  ])
  return model 

但首先,让我们了解什么是 BiLSTM:

A picture containing light  Description automatically generated

图 2.6:LSTM 与 BiLSTM 的对比

在常规的 LSTM 网络中,词语或令牌是单向输入的。以句子“This movie was really good.”为例,从左到右,每个词一个接一个地输入 LSTM 单元,标记为隐含单元。上面的图表显示了一个随时间展开的版本。这意味着每个接下来的词被认为是相对于前一个词的时间增量。每一步会生成一个输出,这个输出可能有用,也可能没有用。这取决于具体问题。在 IMDb 情感预测的例子中,只有最后的输出才是重要的,因为它会被送到密集层做出判断,判断评论是正面还是负面。

如果你在处理像阿拉伯语和希伯来语这样的从右到左书写的语言,请确保按从右到左的顺序输入词令。理解下一个词或词令来自哪个方向非常重要。如果你使用的是 BiLSTM,那么方向可能没有那么重要。

由于时间展开,可能看起来有多个隐含单元。然而,它们实际上是同一个 LSTM 单元,正如本章前面提到的图 2.2所示。单元的输出被送回到同一个单元,作为下一时间步的输入。在 BiLSTM 的情况下,有一对隐含单元。一个集群处理从左到右的词令,而另一个集群处理从右到左的词令。换句话说,正向 LSTM 模型只能从过去的时间步的词令中学习。而 BiLSTM 模型可以同时从过去和未来的词令中学习。

这种方法能够捕捉更多的词语间依赖关系和句子结构,进而提高模型的准确性。假设任务是预测这个句子片段中的下一个词:

我跳入了……

这个句子有许多可能的补全方式。再者,假设你可以访问句子后面的词,想一想这三种可能性:

  1. 我带着一把小刀跳入了……

  2. 我跳入了……并游到了另一岸

  3. 我从 10 米跳板上跳入了……

战斗打斗可能是第一个例子的常见词,第二个是河流,最后一个是游泳池。在每种情况下,句子的开头完全相同,但结尾的词帮助消除歧义,确定应该填入空白的词。这说明了 LSTM 和 BiLSTM 之间的差异。LSTM 只能从过去的词语中学习,而 BiLSTM 能够从过去和未来的词语中学习。

这个新的 BiLSTM 模型有超过 1200 万个参数。

bilstm = build_model_bilstm(
  vocab_size = vocab_size,
  embedding_dim=embedding_dim,
  rnn_units=rnn_units,
  batch_size=BATCH_SIZE)
bilstm.summary() 
Model: "sequential_1"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
embedding_1 (Embedding)      (50, None, 128)           12023168  
_________________________________________________________________
dropout (Dropout)            (50, None, 128)           0         
_________________________________________________________________
bidirectional (Bidirectional (50, None, 128)           98816     
_________________________________________________________________
dropout_1 (Dropout)          (50, None, 128)           0         
_________________________________________________________________
bidirectional_1 (Bidirection (50, 128)                 98816     
_________________________________________________________________
dropout_2 (Dropout)          (50, 128)                 0         
_________________________________________________________________
dense_1 (Dense)              (50, 1)                   129       
=================================================================
Total params: 12,220,929
Trainable params: 12,220,929
Non-trainable params: 0
_________________________________________________________________ 

如果你按照上面所示的模型运行,且没有其他更改,你会看到模型的准确性和精度有所提升:

bilstm.fit(encoded_train_batched, epochs=5) 
Epoch 1/5
500/500 [==============================] - 80s 160ms/step - loss: 0.3731 - accuracy: 0.8270 - Precision: 0.8186 - Recall: 0.8401
…
Epoch 5/5
500/500 [==============================] - 70s 139ms/step - loss: 0.0316 - accuracy: 0.9888 - Precision: 0.9886 - Recall: 0.9889
bilstm.evaluate(encoded_test.batch(BATCH_SIZE))
500/Unknown - 20s 40ms/step - loss: 0.7280 - accuracy: 0.8389 - Precision: 0.8650 - Recall: 0.8032 

注意,模型目前存在严重的过拟合问题。给模型添加某种形式的正则化非常重要。开箱即用的模型,在没有进行特征工程或使用无监督数据来学习更好的嵌入的情况下,模型的准确率已超过 83.5%。2019 年 8 月发布的该数据集当前的最先进结果,准确率为 97.42%。一些可以尝试改进该模型的想法包括:堆叠 LSTM 或 BiLSTM 层,使用一些 dropout 进行正则化,使用数据集的无监督拆分以及训练和测试审查文本数据来学习更好的嵌入,并将其应用到最终网络中,增加更多特征如词形和词性标签等。我们将在第四章使用 BERT 的迁移学习中再次提到这个例子,讨论 BERT 等语言模型。也许这个例子能激发你尝试自己构建模型,并用你最先进的结果发布论文!

请注意,虽然 BiLSTM 非常强大,但并不适用于所有应用。使用 BiLSTM 架构假设整个文本或序列可以同时获取。在某些情况下,这一假设可能并不成立。

在聊天机器人的命令语音识别中,只能使用用户到目前为止说出的声音。我们无法知道用户未来将会说什么词。在实时时间序列分析中,只有过去的数据可用。在这种应用中,BiLSTM 无法使用。此外,值得注意的是,RNN 在大量数据训练和多个 epoch 上才真正能展现其优势。IMDb 数据集包含 25,000 个训练示例,算是较小的数据集,无法充分发挥 RNN 的强大能力。你可能会发现,使用 TF-IDF 和逻辑回归,并结合一些特征工程,能取得相似或更好的结果。

总结

这是我们在高级 NLP 问题探索中的基础性章节。许多高级模型使用像 BiRNN 这样的构建模块。首先,我们使用 TensorFlow Datasets 包来加载数据。通过使用这个库,我们的词汇构建、分词器和向量化编码工作变得简化了。在理解 LSTM 和 BiLSTM 后,我们构建了情感分析模型。我们的工作展示了潜力,但离最先进的结果还有很长的距离,这将在未来的章节中讨论。然而,我们现在已经掌握了构建更复杂模型所需的基本构件,可以帮助我们解决更具挑战性的问题。

拥有了这些 LSTM 的知识后,我们准备在下一章中使用 BiLSTM 构建我们的第一个命名实体识别(NER)模型。一旦模型构建完成,我们将尝试使用 CRF 和维特比解码来改进它。

第三章:基于 BiLSTM、CRF 和 Viterbi 解码的命名实体识别(NER)

自然语言理解(NLU)的一个基础构建模块是命名实体识别NER)。通过 NER,可以在文本中标记出人名、公司名、产品名和数量等实体,这在聊天机器人应用以及信息检索和提取的许多其他用例中都非常有用。NER 将在本章中作为主要内容。构建和训练一个能够进行 NER 的模型需要几种技术,例如条件随机场CRFs)和双向 LSTMBiLSTMs)。还会使用一些高级 TensorFlow 技术,如自定义层、损失函数和训练循环。我们将基于上一章获得的 BiLSTMs 知识进行拓展。具体来说,将涵盖以下内容:

  • NER 概述

  • 构建一个基于 BiLSTM 的 NER 标注模型

  • CRFs 和 Viterbi 算法

  • 为 CRFs 构建自定义 Keras 层

  • 在 Keras 和 TensorFlow 中构建自定义损失函数

  • 使用自定义训练循环训练模型

一切都始于理解 NER,这是下一节的重点。

命名实体识别

给定一句话或一段文本,NER 模型的目标是定位并将文本中的词语分类为命名实体,类别包括人名、组织与公司、地理位置、数量、货币数量、时间、日期,甚至蛋白质或 DNA 序列。NER 应该标记以下句子:

阿什什支付了 80 美元,乘坐 Uber 去 Twitter 位于旧金山的办公室。

如下所示:

[阿什什][PER] 支付了[Uber][ORG] [$80][MONEY] 去[Twitter][ORG] 位于[旧金山][LOC]的办公室。*

这是来自 Google Cloud 自然语言 API 的一个示例,包含多个附加类别:

手机截图  描述自动生成

图 3.1:来自 Google Cloud 自然语言 API 的 NER 示例

最常见的标签列在下表中:

类型 示例标签 示例
人物 PER 格雷戈里去了城堡。
组织 ORG 世界卫生组织刚刚发布了疫情警告。
位置 LOC 她住在西雅图
金钱 MONEY 你欠我二十美元
百分比 PERCENT 股票今天上涨了10%
日期 DATE 我们周三见。
时间 TIME 已经是下午五点了吗?

有不同的数据集和标注方案可以用来训练 NER 模型。不同的数据集会包含上述标注的不同子集。在其他领域,可能会有针对该领域的额外标签。英国的国防科学技术实验室(Defence Science Technology Laboratory)创建了一个名为re3d的数据集(github.com/dstl/re3d),其中包含诸如车辆(如波音 777)、武器(如步枪)和军事*台(如坦克)等实体类型。各种语言中适当大小的标注数据集的可用性是一个重大挑战。以下是一个很好的 NER 数据集集合链接:github.com/juand-r/entity-recognition-datasets。在许多使用案例中,你需要花费大量时间收集和标注数据。例如,如果你正在为订购披萨构建一个聊天机器人,实体可能包括基础、酱料、尺寸和配料。

构建 NER 模型有几种不同的方法。如果将句子视为一个序列,那么这个任务可以建模为逐字标注任务。因此,类似于词性标注POS)的模型是适用的。可以向模型中添加特征以改进标注。一个词的词性及其相邻词汇是最直接可以添加的特征。用于建模小写字母的词形特征可以提供大量信息,主要是因为许多实体类型涉及专有名词,比如人名和组织名。组织名称可能会被缩写。例如,世界卫生组织可以表示为 WHO。请注意,这一特征仅适用于区分大小写字母的语言。

另一个重要的特征是检查一个词是否在地名辞典中。地名辞典就像一个重要地理实体的数据库。可以参考geonames.org上的数据集,该数据集获得了创意共享(Creative Commons)许可。美国社会保障管理局(US Social Security Administration)提供了一份美国人名数据集,地址为www.ssa.gov/oact/babynames/state/namesbystate.zip。该压缩文件包含自 1910 年以来出生在美国的人的名字,按州分组。类似地,知名的邓白氏公司(Dunn and Bradstreet,简称 D&B)提供了一份全球超过 2 亿家企业的数据集,用户可以申请许可。使用这种方法的最大挑战是随着时间的推移维护这些列表的复杂性。

在本章中,我们将关注一个不依赖额外外部数据(如地名词典)进行训练的模型,也不依赖人工特征。我们将尽可能使用深度神经网络和一些额外技术来提高准确度。我们将使用的模型将是 BiLSTM 和 CRF 的组合。该模型基于 Guillaume Lample 等人撰写的论文《命名实体识别的神经网络架构》,并在 2016 年 NAACL-HTL 会议上发表。这篇论文在 2016 年处于前沿水*,F1 分数为 90.94。目前,SOTA(最先进技术)的 F1 分数为 93.5,其中模型使用了额外的训练数据。这些数据是在 CoNLL 2003 英文数据集上测量的。本章将使用 GMB 数据集。下一节将描述该数据集。

GMB 数据集

基础知识掌握后,我们准备构建一个用于分类命名实体识别(NER)的模型。对于这一任务,将使用格罗宁根语义库GMB)数据集。这个数据集并不被认为是黄金标准。也就是说,该数据集是通过自动标注软件构建的,随后由人工评分员更新数据子集。然而,这是一个非常大且丰富的数据集,包含了大量有用的注释,非常适合用于模型训练。它也来源于公共领域的文本,因此很容易用于训练。这个语料库中标注了以下命名实体:

  • geo = 地理实体

  • org = 组织

  • per = 人物

  • gpe = 地缘政治实体

  • tim = 时间指示符

  • art = 人造物

  • eve = 事件

  • nat = 自然现象

在这些类别中,可能存在子类别。例如,tim 可能进一步细分为 tim-dow,表示一周中的某一天,或者 tim-dat,表示一个日期。对于本次练习,这些子实体将被汇总成上述的八个顶级实体。这些子实体的样本数量差异很大,因此,由于某些子类别缺乏足够的训练数据,准确度差异也很大。

数据集还提供了每个单词的 NER 实体。在许多情况下,一个实体可能由多个单词组成。如果Hyde Park是一个地理实体,那么这两个单词都会被标记为geo实体。在训练 NER 模型时,还有另一种表示数据的方式,这对模型的准确性有显著影响。这需要使用 BIO 标注方案。在这种方案中,实体的第一个单词,无论是单一词还是多词,都标记为B-{实体标签}。如果实体是多词的,每个后续的单词将标记为I-{实体标签}。在上面的例子中,Hyde Park将被标记为B-geo I-geo。所有这些都是数据集预处理的步骤。本示例的所有代码可以在 GitHub 仓库的chapter3-ner-with-lstm-crf文件夹中的NER with BiLSTM and CRF.ipynb笔记本中找到。

我们从加载和处理数据开始。

加载数据

数据可以通过格罗宁根大学的网站下载,具体如下:

# alternate: download the file from the browser and put # in the same directory as this notebook
!wget https://gmb.let.rug.nl/releases/gmb-2.2.0.zip
!unzip gmb-2.2.0.zip 

请注意,数据非常大——超过 800MB。如果您的系统中没有wget,可以使用任何其他工具,如curl或浏览器,来下载数据集。此步骤可能需要一些时间才能完成。如果您在从大学服务器访问数据集时遇到问题,可以从 Kaggle 下载一份副本:www.kaggle.com/bradbolliger/gmb-v220。另外,由于我们将处理大数据集,接下来的步骤可能需要一些时间来执行。在自然语言处理NLP)领域,更多的训练数据和训练时间是取得良好结果的关键。

本示例的所有代码可以在 GitHub 仓库的chapter3-ner-with-lstm-crf文件夹中的NER with BiLSTM and CRF.ipynb笔记本中找到。

数据解压后会进入gmb-2.2.0文件夹。data子文件夹中有多个子文件夹和不同的文件。数据集提供的README文件详细说明了各种文件及其内容。在此示例中,我们只使用命名为en.tags的文件,这些文件位于不同的子目录中。这些文件是制表符分隔的文件,每一行表示一个句子的一个单词。

有十列信息:

  • 令牌本身

  • Penn Treebank 中使用的词性标记(ftp://ftp.cis.upenn.edu/pub/treebank/doc/tagguide.ps.gz)

  • 词条

  • 命名实体标签,如果没有则为 0

  • 对应词条-词性组合的 WordNet 词义编号,如果不适用则为 0(wordnet.princeton.edu

  • 对于动词和介词,列出组合范畴语法CCG)推导中按组合顺序排列的 VerbNet 论元角色,如果不适用则为[]verbs.colorado.edu/~mpalmer/projects/verbnet.html

  • 名词-名词复合词中的语义关系、所有格撇号、时间修饰语等。通过介词表示,若不适用则为 0

  • 根据 Zaenen 等人(2004 年)提出的建议,提供一个生动性标签,若不适用则为 0(dl.acm.org/citation.cfm?id=1608954

  • 一个超级标签(CCG 的词汇类别)

  • 用 Boxer 的 Prolog 格式表示的 lambda-DRS,表示标记的语义

在这些字段中,我们只使用标记和命名实体标签。然而,我们将在未来的练习中加载 POS 标签。以下代码获取这些标签文件的所有路径:

import os
data_root = './gmb-2.2.0/data/'
fnames = []
for root, dirs, files in os.walk(data_root):
    for filename in files:
        if filename.endswith(".tags"):
            fnames.append(os.path.join(root, filename))
fnames[:2]
['./gmb-2.2.0/data/p57/d0014/en.tags', './gmb-2.2.0/data/p57/d0382/en.tags'] 

需要进行一些处理步骤。每个文件包含多个句子,每个句子中的单词排列成行。整个句子作为一个序列,与之对应的 NER 标签序列在训练模型时需要一起输入。如前所述,NER 标签也需要简化为仅包含顶级实体。其次,NER 标签需要转换为 IOB 格式。IOB代表In-Other-Begin。这些字母作为前缀附加到 NER 标签上。下表中的句子片段展示了该方案的工作方式:

Reverend Terry Jones arrived in New York
B-per I-per I-per O O B-geo I-geo

上表展示了处理后的标签方案。请注意,New York 是一个地点。一旦遇到New,它标志着地理位置 NER 标签的开始,因此被标记为 B-geo。下一个单词是York,它是同一地理实体的延续。对于任何网络来说,将单词New分类为地理实体的开始将是非常具有挑战性的。然而,BiLSTM 网络能够看到随后的单词,这对消除歧义非常有帮助。此外,IOB 标签的优势在于,在检测方面,模型的准确性显著提高。这是因为一旦检测到 NER 标签的开始,下一标签的选择就会大大受限。

让我们进入代码部分。首先,创建一个目录来存储所有处理后的文件:

!mkdir ner 

我们希望处理这些标签,以便去除 NER 标签中的子类别。还希望收集文档中标签类型的一些统计数据:

import csv
import collections

ner_tags = collections.Counter()
iob_tags = collections.Counter()
def strip_ner_subcat(tag):
    # NER tags are of form {cat}-{subcat}
    # eg tim-dow. We only want first part
    return tag.split("-")[0] 

上面已设置了 NER 标签和 IOB 标签计数器。定义了一个方法来去除 NER 标签中的子类别。下一个方法接受一系列标签并将其转换为 IOB 格式:

def iob_format(ners):
    # converts IO tags into IOB format
    # input is a sequence of IO NER tokens
    # convert this: O, PERSON, PERSON, O, O, LOCATION, O
    # into: O, B-PERSON, I-PERSON, O, O, B-LOCATION, O
    iob_tokens = []
    for idx, token in enumerate(ners):
        if token != 'O':  # !other
            if idx == 0:
                token = "B-" + token #start of sentence
            elif ners[idx-1] == token:
                token = "I-" + token  # continues
            else:
                token = "B-" + token
        iob_tokens.append(token)
        iob_tags[token] += 1
    return iob_tokens 

一旦这两个便捷函数准备好后,所有的标签文件都需要被读取并处理:

total_sentences = 0
outfiles = []
for idx, file in enumerate(fnames):
    with open(file, 'rb') as content:
        data = content.read().decode('utf-8').strip()
        sentences = data.split("\n\n")
        print(idx, file, len(sentences))
        total_sentences += len(sentences)

        with open("./ner/"+str(idx)+"-"+os.path.basename(file), 'w') as outfile:
            outfiles.append("./ner/"+str(idx)+"-"+ os.path.basename(file))
            writer = csv.writer(outfile)

            for sentence in sentences: 
                toks = sentence.split('\n')
                words, pos, ner = [], [], []

                for tok in toks:
                    t = tok.split("\t")
                    words.append(t[0])
                    pos.append(t[1])
                    ner_tags[t[3]] += 1
                    ner.append(strip_ner_subcat(t[3]))
                writer.writerow([" ".join(words), 
                                 " ".join(iob_format(ner)), 
                                 " ".join(pos)]) 

首先,设置一个计数器来统计句子的数量。还初始化了一个包含路径的文件列表。随着处理文件的写入,它们的路径会被添加到outfiles变量中。这个列表将在稍后用于加载所有数据并训练模型。文件被读取并根据两个空行符进行分割。该符号表示文件中句子的结束。文件中仅使用实际的单词、POS 标记和 NER 标记。收集完这些后,将写入一个新的 CSV 文件,包含三列:句子、POS 标签序列和 NER 标签序列。这个步骤可能需要一点时间来执行:

print("total number of sentences: ", total_sentences) 
total number of sentences:  62010 

为了确认 NER 标签在处理前后的分布,我们可以使用以下代码:

print(ner_tags)
print(iob_tags) 
Counter({'O': 1146068, 'geo-nam': 58388, 'org-nam': 48034, 'per-nam': 23790, 'gpe-nam': 20680, 'tim-dat': 12786, 'tim-dow': 11404, 'per-tit': 9800, 'per-fam': 8152, 'tim-yoc': 5290, 'tim-moy': 4262, 'per-giv': 2413, 'tim-clo': 891, 'art-nam': 866, 'eve-nam': 602, 'nat-nam': 300, 'tim-nam': 146, 'eve-ord': 107, 'org-leg': 60, 'per-ini': 60, 'per-ord': 38, 'tim-dom': 10, 'art-add': 1, 'per-mid': 1})
Counter({'O': 1146068, 'B-geo': 48876, 'B-tim': 26296, 'B-org': 26195, 'I-per': 22270, 'B-per': 21984, 'I-org': 21899, 'B-gpe': 20436, 'I-geo': 9512, 'I-tim': 8493, 'B-art': 503, 'B-eve': 391, 'I-art': 364, 'I-eve': 318, 'I-gpe': 244, 'B-nat': 238, 'I-nat': 62}) 

很明显,有些标签非常不常见,比如tim-dom。网络几乎不可能学习到它们。将其聚合到一个层级有助于增加这些标签的信号。为了检查整个过程是否完成,可以检查ner文件夹是否有 10,000 个文件。现在,让我们加载处理后的数据以进行标准化、分词和向量化。

标准化和向量化数据

在本节中,将使用pandasnumpy方法。第一步是将处理过的文件内容加载到一个DataFrame中:

import glob
import pandas as pd
# could use `outfiles` param as well
files = glob.glob("./ner/*.tags")
data_pd = pd.concat([pd.read_csv(f, header=None, 
                                 names=["text", "label", "pos"]) 
                for f in files], ignore_index = True) 

这一步可能需要一些时间,因为它正在处理 10,000 个文件。一旦内容加载完成,我们可以检查DataFrame的结构:

data_pd.info() 
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 62010 entries, 0 to 62009
Data columns (total 3 columns):
 #   Column  Non-Null Count  Dtype 
---  ------  --------------  ----- 
 0   text    62010 non-null  object
 1   label   62010 non-null  object
 2   pos     62010 non-null  object
dtypes: object(3)
memory usage: 1.4+ MB 

文本和 NER 标签都需要被分词并编码成数字,以便用于训练。我们将使用keras.preprocessing包提供的核心方法。首先,将使用分词器来分词文本。在这个例子中,由于文本已经被空格分割开,所以只需要通过空格进行分词:

### Keras tokenizer
from tensorflow.keras.preprocessing.text import Tokenizer
text_tok = Tokenizer(filters='[\\]^\t\n', lower=False,
                     split=' ', oov_token='<OOV>')
pos_tok = Tokenizer(filters='\t\n', lower=False,
                    split=' ', oov_token='<OOV>')
ner_tok = Tokenizer(filters='\t\n', lower=False,
                    split=' ', oov_token='<OOV>') 

分词器的默认值相当合理。然而,在这种特殊情况下,重要的是只按空格进行分词,而不是清理特殊字符。否则,数据会变得格式错误:

text_tok.fit_on_texts(data_pd['text'])
pos_tok.fit_on_texts(data_pd['pos'])
ner_tok.fit_on_texts(data_pd['label']) 

即使我们不使用 POS 标签,处理它们的步骤仍然包括在内。POS 标签的使用会对 NER 模型的准确性产生影响。例如,许多 NER 实体是名词。然而,我们将看到如何处理 POS 标签但不将其作为特征用于模型。这部分留给读者作为练习。

这个分词器有一些有用的功能。它提供了一种通过词频、TF-IDF 等方式限制词汇表大小的方法。如果传入num_words参数并指定一个数字,分词器将根据词频限制令牌的数量为该数字。fit_on_texts方法接受所有文本,将其分词,并构建一个字典,稍后将在一次操作中用于分词和编码。可以在分词器适配完文本后调用方便的get_config()函数,以提供有关令牌的信息:

ner_config = ner_tok.get_config()
text_config = text_tok.get_config()
print(ner_config) 
{'num_words': None, 'filters': '\t\n', 'lower': False, 'split': ' ', 'char_level': False, 'oov_token': '<OOV>', 'document_count': 62010, 'word_counts': '{"B-geo": 48876, "O": 1146068, "I-geo": 9512, "B-per": 21984, "I-per": 22270, "B-org": 26195, "I-org": 21899, "B-tim": 26296, "I-tim": 8493, "B-gpe": 20436, "B-art": 503, "B-nat": 238, "B-eve": 391, "I-eve": 318, "I-art": 364, "I-gpe": 244, "I-nat": 62}', 'word_docs': '{"I-geo": 7738, "O": 61999, "B-geo": 31660, "B-per": 17499, "I-per": 13805, "B-org": 20478, "I-org": 11011, "B-tim": 22345, "I-tim": 5526, "B-gpe": 16565, "B-art": 425, "B-nat": 211, "I-eve": 201, "B-eve": 361, "I-art": 207, "I-gpe": 224, "I-nat": 50}', 'index_docs': '{"10": 7738, "2": 61999, "3": 31660, "7": 17499, "6": 13805, "5": 20478, "8": 11011, "4": 22345, "11": 5526, "9": 16565, "12": 425, "17": 211, "15": 201, "13": 361, "14": 207, "16": 224, "18": 50}', 'index_word': '{"1": "<OOV>", "2": "O", "3": "B-geo", "4": "B-tim", "5": "B-org", "6": "I-per", "7": "B-per", "8": "I-org", "9": "B-gpe", "10": "I-geo", "11": "I-tim", "12": "B-art", "13": "B-eve", "14": "I-art", "15": "I-eve", "16": "I-gpe", "17": "B-nat", "18": "I-nat"}', 'word_index': '{"<OOV>": 1, "O": 2, "B-geo": 3, "B-tim": 4, "B-org": 5, "I-per": 6, "B-per": 7, "I-org": 8, "B-gpe": 9, "I-geo": 10, "I-tim": 11, "B-art": 12, "B-eve": 13, "I-art": 14, "I-eve": 15, "I-gpe": 16, "B-nat": 17, "I-nat": 18}'} 

配置中的index_word字典属性提供了 ID 与标记之间的映射。配置中包含了大量信息。词汇表可以从配置中获取:

text_vocab = eval(text_config['index_word'])
ner_vocab = eval(ner_config['index_word'])
print("Unique words in vocab:", len(text_vocab))
print("Unique NER tags in vocab:", len(ner_vocab)) 
Unique words in vocab: 39422
Unique NER tags in vocab: 18 

对文本和命名实体标签进行分词和编码是非常简单的:

x_tok = text_tok.texts_to_sequences(data_pd['text'])
y_tok = ner_tok.texts_to_sequences(data_pd['label']) 

由于序列的大小不同,它们将被填充或截断为 50 个标记的大小。为此任务使用了一个辅助函数:

# now, pad sequences to a maximum length
from tensorflow.keras.preprocessing import sequence
max_len = 50
x_pad = sequence.pad_sequences(x_tok, padding='post',
                              maxlen=max_len)
y_pad = sequence.pad_sequences(y_tok, padding='post',
                              maxlen=max_len)
print(x_pad.shape, y_pad.shape) 
(62010, 50) (62010, 50) 

上面最后一步是确保在进入下一步之前,形状是正确的。验证形状是开发 TensorFlow 代码中非常重要的一部分。

需要对标签执行额外的步骤。由于有多个标签,每个标签标记需要进行独热编码,如下所示:

num_classes = len(ner_vocab) + 1
Y = tf.keras.utils.to_categorical(y_pad, num_classes=num_classes)
Y.shape 
(62010, 50, 19) 

现在,我们准备好构建和训练模型了。

一个 BiLSTM 模型

我们将尝试的第一个模型是 BiLSTM 模型。首先,需要设置基本常量:

# Length of the vocabulary 
vocab_size = len(text_vocab) + 1 
# The embedding dimension
embedding_dim = 64
# Number of RNN units
rnn_units = 100
#batch size
BATCH_SIZE=90
# num of NER classes
num_classes = len(ner_vocab)+1 

接下来,定义一个便捷函数来实例化模型:

from tensorflow.keras.layers import Embedding, Bidirectional, LSTM, TimeDistributed, Dense
dropout=0.2
def build_model_bilstm(vocab_size, embedding_dim, rnn_units, batch_size, classes):
  model = tf.keras.Sequential([
    Embedding(vocab_size, embedding_dim, mask_zero=True,
                              batch_input_shape=[batch_size,
 None]),
    Bidirectional(LSTM(units=rnn_units,
                           return_sequences=True,
                           dropout=dropout,  
                           kernel_initializer=\
                            tf.keras.initializers.he_normal())),
    **TimeDistributed(Dense(rnn_units, activation=****'relu'****)),**
    Dense(num_classes, activation="softmax")
  ]) 

我们将训练我们自己的嵌入层。下一章将讨论如何使用预训练的嵌入层并将其应用于模型。在嵌入层之后是一个 BiLSTM 层,接着是一个TimeDistributed全连接层。这个最后的层与情感分析模型有所不同,情感分析模型只有一个用于二分类输出的单元。而在这个问题中,对于输入序列中的每个单词,都需要预测一个 NER 标记。因此,输出的标记与输入的标记一一对应,并被分类为其中一个 NER 类别。TimeDistributed层提供了这种能力。这个模型的另一个需要注意的地方是正则化的使用。确保模型不会过拟合训练数据非常重要。由于 LSTM 具有较高的模型容量,因此使用正则化非常重要。可以随意调整这些超参数,看看模型的反应。

现在可以编译模型了:

model = build_model_bilstm(
                        vocab_size = vocab_size,
                        embedding_dim=embedding_dim,
                        rnn_units=rnn_units,
                        batch_size=BATCH_SIZE,
                        classes=num_classes)
model.summary()
model.compile(optimizer="adam", loss="categorical_crossentropy",
 metrics=["accuracy"]) 
Model: "sequential_1"
Layer (type)                 Output Shape              Param #   
=================================================================
embedding_9 (Embedding)      (90, None, 64)            2523072   
_________________________________________________________________
bidirectional_9 (Bidirection (90, None, 200)           132000    
_________________________________________________________________
time_distributed_6 (TimeDist (None, None, 100)         20100     
_________________________________________________________________
dense_16 (Dense)             (None, None, 19)          1919      
=================================================================
Total params: 2,677,091
Trainable params: 2,677,091
Non-trainable params: 0
_________________________________________________________________ 

这个简单的模型有超过 260 万个参数!

如果你注意到,大部分参数来自词汇表的大小。词汇表包含 39,422 个单词。这增加了模型的训练时间和所需的计算能力。减少这个问题的一种方法是将词汇表的大小减小。最简单的方法是只考虑出现频率超过某个阈值的单词,或者去除小于某个字符数的单词。还可以通过将所有字符转换为小写来减少词汇表的大小。然而,在 NER 任务中,大小写是一个非常重要的特征。

这个模型已经准备好进行训练了。最后需要做的事情是将数据拆分为训练集和测试集:

# to enable TensorFlow to process sentences properly
X = x_pad
# create training and testing splits
total_sentences = 62010
test_size = round(total_sentences / BATCH_SIZE * 0.2)
X_train = X[BATCH_SIZE*test_size:]
Y_train = Y[BATCH_SIZE*test_size:]
X_test = X[0:BATCH_SIZE*test_size]
Y_test = Y[0:BATCH_SIZE*test_size] 

现在,模型已准备好进行训练:

model.fit(X_train, Y_train, batch_size=BATCH_SIZE, epochs=15) 
Train on 49590 samples
Epoch 1/15
49590/49590 [==============================] - 20s 409us/sample - loss: 0.1736 - accuracy: 0.9113
...
Epoch 8/15
49590/49590 [==============================] - 15s 312us/sample - loss: 0.0153 - accuracy: 0.9884
...
Epoch 15/15
49590/49590 [==============================] - 15s 312us/sample - loss: 0.0065 - accuracy: 0.9950 

经过 15 个 epochs 的训练,模型表现得相当不错,准确率超过 99%。让我们看看模型在测试集上的表现,正则化是否有所帮助:

model.evaluate(X_test, Y_test, batch_size=BATCH_SIZE) 
12420/12420 [==============================] - 3s 211us/sample - loss: 0.0926 - accuracy: 0.9624 

该模型在测试数据集上的表现良好,准确率超过 96.5%。训练集与测试集的准确率仍有差距,意味着该模型可能需要更多的正则化。你可以调整丢弃率变量,或者在嵌入层和 BiLSTM 层之间,TimeDistributed 层和最终的 Dense 层之间添加额外的丢弃层。

这是该模型标记的一个句子片段的示例:

法乌尔 格纳辛贝 在周五的讲话中表示 由国家媒体报道
实际 B-per I-per O O
模型 B-per I-per O O

该模型表现并不差。它能够识别句子中的人名和时间实体。

尽管该模型表现优秀,但它没有利用命名实体标签的一个重要特征——一个给定的标签与后续标签高度相关。CRFs 可以利用这一信息,进一步提高 NER 任务的准确性。接下来我们将理解 CRFs 的工作原理,并将其添加到上述网络中。

条件随机场(CRFs)

BiLSTM 模型查看一系列输入词,并预测当前词的标签。在做出这一决定时,仅考虑之前输入的信息。之前的预测不在做出决策时起作用。然而,标签序列中编码的信息却被忽略了。为了说明这一点,考虑一组 NER 标签:OB-PerI-PerB-GeoI-Geo。这代表了两种实体领域:人名和地理名,以及一个表示其他所有实体的 Other 类别。基于 IOB 标签的结构,我们知道任何 I 标签必须由同一领域的 B-I 标签之前标记。这也意味着 I 标签不能由 O 标签之前标记。下图展示了这些标签之间可能的状态转换:

图 3.2:可能的 NER 标签转换

图 3.2 使用颜色编码相似类型的转换,以相同颜色表示。O 标签只能转换为 B 标签。B 标签可以转换为其对应的 I 标签,或者回到 O 标签。I 标签可以转回自身、O 标签,或者不同领域的 B 标签(为简化图示,未在图中表示)。对于一组 N 个标签,这些转换可以用一个 N x N 的矩阵表示。P[i,j] 表示标签 j 紧随标签 i 之后的可能性。请注意,这些转换权重可以基于数据进行学习。在预测过程中,可以使用这种学习到的转换权重矩阵,考虑整个预测标签序列并更新概率。

以下是一个示意矩阵,显示了指示性的转换权重:

从 > 到 O B-Geo I-Geo B-Org I-Org
O 3.28 2.20 0.00 3.66 0.00
B-Geo -0.25 -0.10 4.06 0.00 0.00
I-Geo -0.17 -0.61 3.51 0.00 0.00
B-Org -0.10 -0.23 0.00 -1.02 4.81
I-Org -0.33 -1.75 0.00 -1.38 5.10

根据上表,从 I-Org 到 B-Org 的边的权重为-1.38,意味着这种转移发生的可能性极低。实际上,实现 CRF 有三个主要步骤。第一步是修改 BiLSTM 层生成的得分,并考虑转移权重,如上所示。一个预测序列

由上述 BiLSTM 层为一系列n标签生成的得分,在k个唯一标签的空间中是可用的,它作用于输入序列XP表示一个维度为n × k的矩阵,其中元素P[i,j]表示在位置y[i]处输出标签j的概率。让A成为如上所示的转移概率的方阵,维度为(k + 2) × (k + 2),因为在句子的开始和结束标记处增加了两个额外的标记。元素A[i,j]表示从i到标签j的转移概率。使用这些值,可以计算出新的得分,方式如下:

可以对所有可能的标签序列计算 softmax,以获取给定序列y的概率:

Y[X]表示所有可能的标签序列,包括那些可能不符合 IOB 标签格式的序列。为了使用这个 softmax 进行训练,可以计算其对数似然。通过巧妙地使用动态规划,可以避免组合爆炸,且分母可以高效地计算。

这里只展示了简单的数学公式,以帮助建立这种方法如何工作的直觉。实际的计算将在下面的自定义层实现中更加明确。

在解码时,输出序列是这些可能序列中得分最高的序列,这个得分通过概念上使用argmax风格的函数计算。维特比算法通常用于实现动态规划解码。首先,我们先编写模型并进行训练,然后再进入解码部分。

带有 BiLSTM 和 CRF 的命名实体识别(NER)

实现带有 CRF 的 BiLSTM 网络需要在上述开发的 BiLSTM 网络上添加一个 CRF 层。然而,CRF 并不是 TensorFlow 或 Keras 层的核心部分。它可以通过tensorflow_addonstfa包来使用。第一步是安装这个包:

!pip install tensorflow_addons==0.11.2 

有许多子包,但 CRF 的便捷函数位于tfa.text子包中:

手机截图,自动生成的描述

图 3.3:tfa.text 方法

尽管提供了实现 CRF 层的低级方法,但并没有提供类似高级层的构造。实现 CRF 需要一个自定义层、损失函数和训练循环。训练完成后,我们将查看如何实现一个自定义的推理函数,该函数将使用 Viterbi 解码。

实现自定义的 CRF 层、损失函数和模型

与上面的流程类似,模型中将包含一个嵌入层和一个 BiLSTM 层。BiLSTM 层的输出需要使用上文描述的 CRF 对数似然损失进行评估。这是训练模型时需要使用的损失函数。实现的第一步是创建一个自定义层。在 Keras 中实现自定义层需要继承 keras.layers.Layer 类。需要实现的主要方法是 call(),该方法接收层的输入,对其进行转换,并返回结果。此外,层的构造函数还可以设置所需的任何参数。我们先从构造函数开始:

from tensorflow.keras.layers import Layer
from tensorflow.keras import backend as K
class CRFLayer(Layer):
  """
  Computes the log likelihood during training
  Performs Viterbi decoding during prediction
  """
  def __init__(self,
               label_size, mask_id=0,
               trans_params=None, name='crf',
               **kwargs):
    super(CRFLayer, self).__init__(name=name, **kwargs)
    self.label_size = label_size
    self.mask_id = mask_id
    self.transition_params = None

    if trans_params is None:  # not reloading pretrained params
        self.transition_params = tf.Variable(
tf.random.uniform(shape=(label_size, label_size)),
                trainable=False)
    else:
        self.transition_params = trans_params 

所需的主要参数有:

  • 标签数量和转移矩阵:如上文所述,需要学习一个转移矩阵。这个方阵的维度等于标签的数量。转移矩阵使用参数初始化。该转移参数矩阵无法通过梯度下降进行训练。它是计算对数似然的结果。转移参数矩阵也可以在过去已学习的情况下传递给该层。

  • 掩码 ID:由于序列是填充的,因此在计算转移得分时,恢复原始序列长度非常重要。按照约定,掩码使用值 0,且这是默认值。这个参数为将来的可配置性做好了设置。

第二个方法是计算应用该层后的结果。请注意,作为一层,CRF 层仅在训练时重复输出。CRF 层仅在推理时有用。在推理时,它使用转移矩阵和逻辑来纠正 BiLSTM 层输出的序列,然后再返回它们。现在,这个方法相对简单:

def call(self, inputs, seq_lengths, training=None):

    if training is None:
        training = K.learning_phase()

    # during training, this layer just returns the logits
    if training:
        return inputs
    return inputs  # to be replaced later 

该方法接收输入以及一个参数,该参数帮助指定此方法是用于训练时调用还是用于推理时调用。如果未传递此变量,它将从 Keras 后端获取。当使用 fit() 方法训练模型时,learning_phase() 返回 True。当调用模型的 .predict() 方法时,这个标志将被设置为 false

由于传递的序列是被掩码的,因此在推理时该层需要知道真实的序列长度以进行解码。为此,传递了一个变量,但当前未使用它。现在基本的 CRF 层已准备就绪,让我们开始构建模型。

一个自定义的 CRF 模型

由于模型在构建时除了自定义的 CRF 层外,还依赖于多个预先存在的层,因此显式的导入语句有助于提高代码的可读性:

from tensorflow.keras import Model, Input, Sequential
from tensorflow.keras.layers import LSTM, Embedding, Dense, TimeDistributed
from tensorflow.keras.layers import Dropout, Bidirectional
from tensorflow.keras import backend as K 

第一步是定义一个构造函数,该构造函数将创建各种层并存储适当的维度:

class NerModel(tf.keras.Model):
    def __init__(self, hidden_num, vocab_size, label_size, 
                 embedding_size, 
                 name='BilstmCrfModel', **kwargs):
        super(NerModel, self).__init__(name=name, **kwargs)
        self.num_hidden = hidden_num
        self.vocab_size = vocab_size
        self.label_size = label_size
        self.embedding = Embedding(vocab_size, embedding_size, 
                                   mask_zero=True, 
                                   name="embedding")
        self.biLSTM =Bidirectional(LSTM(hidden_num, 
                                   return_sequences=True), 
                                   name="bilstm")
        self.dense = TimeDistributed(tf.keras.layers.Dense(
                                     label_size), name="dense")
        self.crf = CRFLayer(self.label_size, name="crf") 

该构造函数接收 BiLSTM 后续层的隐藏单元数、词汇表中单词的大小、NER 标签的数量以及嵌入的大小。此外,构造函数会设置一个默认名称,可以在实例化时进行覆盖。任何额外的参数都会作为关键字参数传递。

在训练和预测过程中,将调用以下方法:

def call(self, text, labels=None, training=None):
        seq_lengths = tf.math.reduce_sum(
tf.cast(tf.math.not_equal(text, 0), dtype=tf.int32), axis=-1) 

        if training is None:
            training = K.learning_phase()
        inputs = self.embedding(text)
        bilstm = self.biLSTM(inputs)
        logits = self.dense(bilstm)
        outputs = self.crf(logits, seq_lengths, training)
        return outputs 

因此,通过几行代码,我们已经使用上述自定义 CRF 层实现了一个自定义模型。现在唯一需要的就是一个损失函数来训练该模型。

使用 CRF 的自定义损失函数进行命名实体识别(NER)

让我们将损失函数作为 CRF 层的一部分来实现,并封装在一个同名的函数中。请注意,调用此函数时,通常会传递标签和预测值。我们将基于 TensorFlow 中的自定义损失函数来建模我们的损失函数。将以下代码添加到 CRF 层类中:

 def loss(self, y_true, y_pred):
    y_pred = tf.convert_to_tensor(y_pred)
    y_true = tf.cast(self.get_proper_labels(y_true), y_pred.dtype)
    seq_lengths = self.get_seq_lengths(y_true)
    log_likelihoods, self.transition_params =\ 
tfa.text.crf_log_likelihood(y_pred,
               y_true, seq_lengths)
    # save transition params
    self.transition_params = tf.Variable(self.transition_params, 
      trainable=False)
    # calc loss
    loss = - tf.reduce_mean(log_likelihoods)
    return loss 

该函数接收真实标签和预测标签。这两个张量通常具有形状(批量大小,最大序列长度,NER 标签数量)。然而,tfa 包中的对数似然函数要求标签的形状为(批量大小,最大序列长度)张量。因此,使用一个便利函数,它作为 CRF 层的一部分,如下所示,用于执行标签形状的转换:

 def get_proper_labels(self, y_true):
    shape = y_true.shape
    if len(shape) > 2:
        return tf.argmax(y_true, -1, output_type=tf.int32)
    return y_true 

对数似然函数还需要每个样本的实际序列长度。这些序列长度可以从标签和在该层构造函数中设置的掩码标识符中计算出来(见上文)。这个过程被封装在另一个便利函数中,也属于 CRF 层的一部分:

 def get_seq_lengths(self, matrix):
    # matrix is of shape (batch_size, max_seq_len)
    mask = tf.not_equal(matrix, self.mask_id)
    seq_lengths = tf.math.reduce_sum(
                                    tf.cast(mask, dtype=tf.int32), 
                                    axis=-1)
    return seq_lengths 

首先,通过将标签的值与掩码 ID 进行比较,生成一个布尔掩码。然后,通过将布尔值转换为整数并对行进行求和,重新生成序列的长度。接着,调用 tfa.text.crf_log_likelihood() 函数来计算并返回对数似然值和转移矩阵。CRF 层的转移矩阵将根据函数调用返回的转移矩阵进行更新。最后,通过对所有返回的对数似然值求和来计算损失。

到此为止,我们的自定义模型已经准备好开始训练。我们需要设置数据并创建自定义训练循环。

实现自定义训练

模型需要实例化并初始化以进行训练:

# Length of the vocabulary 
vocab_size = len(text_vocab) + 1 
# The embedding dimension
embedding_dim = 64
# Number of RNN units
rnn_units = 100
#batch size
BATCH_SIZE=90
# num of NER classes
num_classes = len(ner_vocab) + 1
blc_model = NerModel(rnn_units, vocab_size, num_classes, 
embedding_dim, dynamic=True)
optimizer = tf.keras.optimizers.Adam(learning_rate=1e-3) 

与之前的示例一样,将使用 Adam 优化器。接下来,我们将从上述 BiLSTM 部分加载的 DataFrame 构建 tf.data.DataSet

# create training and testing splits
total_sentences = 62010
test_size = round(total_sentences / BATCH_SIZE * 0.2)
X_train = x_pad[BATCH_SIZE*test_size:]
Y_train = Y[BATCH_SIZE*test_size:]
X_test = x_pad[0:BATCH_SIZE*test_size]
Y_test = Y[0:BATCH_SIZE*test_size]
Y_train_int = tf.cast(Y_train, dtype=tf.int32)
train_dataset = tf.data.Dataset.from_tensor_slices((X_train, Y_train_int))
train_dataset = train_dataset.batch(BATCH_SIZE, drop_remainder=True) 

大约 20% 的数据将保留用于测试,其余部分用于训练。

为了实现自定义训练循环,TensorFlow 2.0 提供了梯度带(gradient tape)。这允许对训练任何模型时所需的主要步骤进行低级别的管理,这些步骤包括:

  1. 计算前向传播的预测结果

  2. 计算这些预测与标签比较时的损失

  3. 计算基于损失函数的可训练参数的梯度,然后使用优化器来调整权重

让我们训练这个模型 5 个时期,并观察随着训练的进行,损失是如何变化的。与之前模型训练 15 个时期的情况进行对比。自定义训练循环如下所示:

loss_metric = tf.keras.metrics.Mean()
epochs = 5
# Iterate over epochs.
for epoch in range(epochs):
    print('Start of epoch %d' % (epoch,))
    # Iterate over the batches of the dataset.
    for step, (text_batch, labels_batch) in enumerate(
train_dataset):
        labels_max = tf.argmax(labels_batch, -1, 
output_type=tf.int32)
        with tf.GradientTape() as tape:
            **logits = blc_model(text_batch, training=****True****)**
            loss = blc_model.crf.loss(labels_max, logits)
            grads = tape.gradient(loss, 
blc_model.trainable_weights)
            optimizer.apply_gradients(zip(grads, 
blc_model.trainable_weights))

            loss_metric(loss)
        if step % 50 == 0:
          print('step %s: mean loss = %s' % 
(step, loss_metric.result())) 

创建一个指标来跟踪*均损失随时间的变化。在 5 个时期内,输入和标签每次从训练数据集中提取一批。使用 tf.GradientTape() 跟踪操作,按照上面列出的步骤实现。请注意,由于这是自定义训练循环,我们手动传递了可训练变量。最后,每 50 步打印一次损失指标,以显示训练进度。下面是略去部分内容后的结果:

Start of epoch 0
step 0: mean loss = tf.Tensor(71.14853, shape=(), dtype=float32)
step 50: mean loss = tf.Tensor(31.064453, shape=(), dtype=float32)
...
Start of epoch 4
step 0: mean loss = tf.Tensor(4.4125915, shape=(), dtype=float32)
step 550: mean loss = tf.Tensor(3.8311224, shape=(), dtype=float32) 

由于我们实现了自定义训练循环,并且不需要模型编译,因此之前无法获取模型参数的总结。现在,为了了解模型的规模,可以获取一个摘要:

blc_model.summary() 
Model: "BilstmCrfModel"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
embedding (Embedding)        multiple                  2523072   
_________________________________________________________________
bilstm (Bidirectional)       multiple                  132000    
_________________________________________________________________
dense (TimeDistributed)      multiple                  3819      
_________________________________________________________________
crf (CRFLayer)               multiple                  361       
=================================================================
Total params: 2,659,252
Trainable params: 2,658,891
Non-trainable params: 361
_________________________________________________________________ 

它的大小与之前的模型相当,但有一些不可训练的参数。这些参数来自转移矩阵。转移矩阵并不是通过梯度下降学习的,因此它们被归类为不可训练参数。

然而,训练损失很难解释。为了计算准确率,我们需要实现解码,这是下一节的重点。暂时假设解码已经实现,并检查训练 5 个时期后的结果。为了便于说明,这里有一个来自测试集的句子,显示了在第一个时期结束时和 5 个时期结束时的结果。

示例句子是:

Writing in The Washington Post newspaper , Mr. Ushakov also 
said it is inadmissible to move in the direction of demonizing Russia . 

对应的真实标签是:

O O B-org I-org I-org O O B-per B-org O O O O O O O O O O O O B-geo O 

这是一个对于命名实体识别(NER)来说很有挑战的例子,其中 The Washington Post 被标记为一个三词的组织名,第一个词非常常见,并在多个语境中使用,第二个词也是一个地理位置的名称。还需要注意 GMB 数据集中的标签不完善,其中名字 Ushakov 的第二个标签被标记为一个组织。训练的第一个时期结束时,模型预测结果为:

O O O B-geo I-org O O B-per I-per O O O O O O O O O O O O B-geo O 

它在组织名没有出现在预期位置时会产生混淆。它还显示它没有学会转移概率,因为它在 B-geo 标签后面放了一个 I-org 标签。然而,它在处理人物部分时没有犯错。不幸的是,模型并不会因为准确预测人物标签而得到奖励,由于标签的不完善,这仍然会被算作一次漏检。经过五个时期的训练后,结果比最初要好:

O O B-org I-org I-org O O B-per I-per O O O O O O O O O O O O B-geo O 

这是一个很好的结果,考虑到我们所做的训练有限。现在,让我们看看如何在 CRF 层中解码句子以得到这些序列。用于解码的算法称为维特比解码器。

维特比解码

一种直接预测标签序列的方法是输出来自网络前一层激活值最高的标签。然而,这可能是次优的,因为它假设每个标签的预测与之前或之后的预测是独立的。维特比算法用于获取序列中每个单词的预测,并应用最大化算法,使得输出序列具有最高的可能性。在未来的章节中,我们将看到通过束搜索(beam search)实现相同目标的另一种方法。维特比解码涉及对整个序列进行最大化,而不是对序列中的每个单词进行优化。为了说明这个算法和思维方式,让我们以一个包含 5 个单词的句子和 3 个标签的集合为例。这些标签可以是 O、B-geo 和 I-geo。

该算法需要标签之间的转换矩阵值。回忆一下,这些值是通过上面的自定义 CRF 层生成并存储的。假设矩阵如下所示:

从 > 到 掩码 O B-geo I-geo
掩码 0.6 0.3 0.2 0.01
O 0.8 0.5 0.6 0.01
B-geo 0.2 0.4 0.01 0.7
I-geo 0.3 0.4 0.01 0.5

为了说明算法是如何工作的,下面将使用所示的图形:

图 3.4:维特比解码器的步骤

句子从左侧开始。箭头从单词的起始位置指向第一个标记,表示两个标记之间转换的概率。箭头上的数字应该与上方转换矩阵中的值相匹配。在表示标签的圆圈内,显示了神经网络(在我们这个案例中是 BiLSTM 模型)为第一个单词生成的分数。这些分数需要加在一起,得到单词的最终分数。请注意,我们将术语从概率改为分数,因为在这个特定示例中没有执行归一化操作。

第一个单词标签的概率

O的分数:0.3(转换分数)+ 0.2(激活分数)= 0.5

B-geo的分数:0.2(转换分数)+ 0.3(激活分数)= 0.5

I-geo的分数:0.01(转换分数)+ 0.01(激活分数)= 0.02

在这一点上,O标签或B-geo标签作为起始标签的可能性是一样的。让我们考虑下一个标签,并使用相同的方法计算以下序列的分数:

(O, B-geo) = 0.6 + 0.3 = 0.9 (B-geo, O) = 0.4 + 0.3 = 0.7
(O, I-geo) = 0.01 + 0.25 = 0.26 (B-geo, B-geo) = 0.01 + 0.3 = 0.31
(O, O) = 0.5 + 0.3 = 0.8 (B-geo, I-geo) = 0.7 + 0.25 = 0.95

这个过程被称为前向传播。还应注意,尽管这是一个人为构造的示例,但考虑到先前的标签时,给定输入的激活可能并不是最好的预测该词正确标签的依据。如果句子只有两个词,那么可以通过每一步相加来计算各种序列的得分:

(Start, O, B-Geo) = 0.5 + 0.9 = 1.4 (Start, B-Geo, O) = 0.5 + 0.7 = 1.2
(Start, O, O) = 0.5 + 0.8 = 1.3 (Start, B-geo, B-geo) = 0.5 + 0.31 = 0.81
(Start, O, I-Geo) = 0.5 + 0.26 = 0.76 (Start, B-geo, I-geo) = 0.5 + 0.95 = 1.45

如果仅考虑激活得分,那么最可能的序列将是 (Start, B-geo, O) 或 (Start, B-geo, B-geo)。然而,使用转换得分和激活值一起考虑时,得分最高的序列是 (Start, B-geo, I-geo)。虽然前向传播给出了给定最后一个标记时整个序列的最高得分,反向传播过程将重构出导致该最高得分的序列。这实际上是维特比算法,它使用动态规划以高效的方式执行这些步骤。

实现这个算法的一个优势是,核心计算已作为 tfa 包中的方法提供。这个解码步骤将在上面实现的 CRF 层的 call() 方法中实现。修改此方法如下所示:

 def call(self, inputs, seq_lengths, training=None):
    if training is None:
        training = K.learning_phase()

    # during training, this layer just returns the logits
    if training:
        return inputs

    **# viterbi decode logic to return proper** 
    **# results at inference**
    **_, max_seq_len, _ = inputs.shape**
    **seqlens = seq_lengths**
    **paths = []**
    **for** **logit, text_len** **in****zip****(inputs, seqlens):**
        **viterbi_path, _ = tfa.text.viterbi_decode(logit[:text_len],** 
                                              **self.transition_params)**
        **paths.append(self.pad_viterbi(viterbi_path, max_seq_len))**
    **return** **tf.convert_to_tensor(paths)** 

新添加的行已被突出显示。viterbi_decode()方法将前一层的激活值和转换矩阵与最大序列长度一起使用,计算出得分最高的路径。此得分也会返回,但在推理过程中我们忽略它。这个过程需要对批次中的每个序列执行。请注意,此方法返回不同长度的序列。这使得转换为张量变得更加困难,因此使用了一个实用程序函数来填充返回的序列:

 def pad_viterbi(self, viterbi, max_seq_len):
    if len(viterbi) < max_seq_len:
        viterbi = viterbi + [self.mask_id] * \
                                (max_seq_len - len(viterbi))
    return viterbi 

Dropout 层的工作方式完全与 CRF 层相反。Dropout 层只在训练时修改输入数据。在推理时,它只是将所有输入传递过去。

我们的 CRF 层的工作方式完全相反。它在训练时传递输入数据,但在推理时使用维特比解码器转换输入。注意 training 参数的使用来控制行为。

现在,层已修改并准备好,模型需要重新实例化并进行训练。训练后,可以像这样进行推理:

Y_test_int = tf.cast(Y_test, dtype=tf.int32)
test_dataset = tf.data.Dataset.from_tensor_slices((X_test,
                                                   Y_test_int))
test_dataset = test_dataset.batch(BATCH_SIZE, drop_remainder=True)
out = blc_model.predict(test_dataset.take(1)) 

这将对一小批测试数据进行推理。让我们查看示例句子的结果:

text_tok.sequences_to_texts([X_test[2]]) 
['Writing in The Washington Post newspaper , Mr. Ushakov also said it is inadmissible to move in the direction of demonizing Russia . <OOV> <OOV> <OOV> <OOV> <OOV> <OOV> <OOV> <OOV> <OOV> <OOV> <OOV> <OOV> <OOV> <OOV> <OOV> <OOV> <OOV> <OOV> <OOV> <OOV> <OOV> <OOV> <OOV> <OOV> <OOV> <OOV> <OOV>'] 

正如我们在突出显示的输出中看到的,结果比实际数据更好!

print("Ground Truth: ", 
ner_tok.sequences_to_texts([tf.argmax(Y_test[2], 
                                     -1).numpy()]))
print("Prediction: ", ner_tok.sequences_to_texts([out[2]])) 
Ground Truth:  ['O O B-org I-org I-org O O **B-per B-org** O O O O O O O O O O O O B-geo O <OOV> <SNIP> <OOV>']
Prediction:  ['O O B-org I-org I-org O O **B-per I-per** O O O O O O O O O O O O B-geo O <OOV> <SNIP> <OOV>'] 

为了评估训练的准确性,需要实现一个自定义方法。如下所示:

def np_precision(pred, true):
    # expect numpy arrays
    assert pred.shape == true.shape
    assert len(pred.shape) == 2
    mask_pred = np.ma.masked_equal(pred, 0)
    mask_true = np.ma.masked_equal(true, 0)
    acc = np.equal(mask_pred, mask_true)
    return np.mean(acc.compressed().astype(int)) 

使用numpyMaskedArray功能,比较预测结果和标签并将其转换为整数数组,然后计算均值以计算准确率:

np_precision(out, tf.argmax(Y_test[:BATCH_SIZE], -1).numpy()) 
0.9664461247637051 

这是一个相当准确的模型,仅经过 5 个周期的训练,并且架构非常简单,同时使用的是从零开始训练的词嵌入。召回率指标也可以以类似的方式实现。前面展示的仅使用 BiLSTM 的模型,经过 15 个周期的训练才达到了类似的准确度!

这完成了使用 BiLSTM 和 CRF 实现 NER 模型。如果这对你有兴趣,并且你想继续研究这个领域,可以查找 CoNLL 2003 数据集用于 NER。即使在今天,仍然有许多论文发布,旨在提高基于该数据集的模型的准确性。

总结

本章已经涵盖了相当多的内容。我们解释了命名实体识别(NER)及其在行业中的重要性。要构建 NER 模型,需要 BiLSTM 和 CRF。通过使用我们在上一章中构建情感分类模型时学到的 BiLSTM,我们构建了一个可以标注命名实体的模型的第一个版本。这个模型后来通过使用 CRF 进行了改进。在构建这些模型的过程中,我们介绍了 TensorFlow DataSet API 的使用。我们还通过构建自定义 Keras 层、自定义模型、自定义损失函数和自定义训练循环,构建了用于 CRF 模式的高级模型。

到目前为止,我们已经为模型中的标记训练了词嵌入。通过使用预训练的词嵌入,可以获得相当大的提升。在下一章,我们将重点讨论迁移学习的概念以及使用像 BERT 这样的预训练嵌入。

第四章:使用 BERT 进行迁移学习

深度学习模型在大量训练数据下表现最好。在该领域,特别是在自然语言处理(NLP)中,拥有足够的标注数据一直是一个巨大的挑战。*年来,迁移学习作为一种成功的方法,取得了显著的成果。模型首先在一个大型语料库上以无监督或半监督的方式进行训练,然后针对特定应用进行微调。这些模型已经展示了出色的效果。在本章中,我们将基于 IMDb 电影评论情感分析的任务,使用迁移学习构建基于GloVe全局词向量表示)预训练词向量和BERT双向编码器表示变换器)上下文模型的模型。本章将涵盖以下内容:

  • 迁移学习概述及其在 NLP 中的应用

  • 在模型中加载预训练的 GloVe 词向量

  • 使用预训练的 GloVe 词向量和微调构建情感分析模型

  • 使用 Attention 的上下文词嵌入概述 – BERT

  • 使用 Hugging Face 库加载预训练的 BERT 模型

  • 使用预训练和自定义的基于 BERT 的微调模型进行情感分析

迁移学习是使 NLP 取得快速进展的核心概念,我们首先讨论迁移学习。

迁移学习概述

传统上,机器学习模型是针对特定任务的性能进行训练的。它只期望在该任务上有效,并且不太可能在该任务之外表现得很好。以 IMDb 电影评论情感分类问题为例,参考第二章使用 BiLSTM 理解自然语言中的情感。为这个特定任务训练的模型只针对这个任务进行了优化。如果我们想训练另一个模型,就需要一组与之不同任务相关的标注数据。如果没有足够的标注数据来支持该任务,构建另一个模型可能是无效的。

迁移学习是学习数据的基本表示的概念,这种表示可以适应不同的任务。在迁移学习中,可以使用更丰富的可用数据集来蒸馏知识,并为特定任务构建新的机器学习模型。通过利用这些知识,新的机器学习模型即使在没有足够的标注数据的情况下,也能取得不错的性能,这通常是传统机器学习方法无法获得良好结果的情况。为了使这一方案有效,有几个重要的考虑因素:

  • 知识蒸馏步骤,称为预训练,应该有大量的可用数据,而且这些数据相对便宜

  • 适应性调整,通常称为微调,应该在与预训练数据相似的数据上进行

下面的图示说明了这个概念:

图 4.1:比较传统机器学习与迁移学习

这种技术在计算机视觉中非常有效。ImageNet 通常被用作预训练的数据集。然后,特定的模型会针对各种任务进行微调,如图像分类、物体检测、图像分割和姿势检测等。

迁移学习的类型

领域任务的概念支撑了迁移学习的概念。领域代表了一个特定的知识或数据领域。新闻文章、社交媒体帖子、医疗记录、维基百科条目和法院判决等可以视为不同领域的例子。任务是在领域中的特定目标或动作。推文的情感分析和立场检测是社交媒体帖子领域中的具体任务。癌症和骨折的检测可能是医疗记录领域中的不同任务。不同类型的迁移学习有不同的源领域和目标领域及任务的组合。下面描述了三种主要的迁移学习类型,分别是领域适应、多任务学习和顺序学习。

领域适应

在这种设置下,源任务和目标任务的领域通常是相同的。然而,它们之间的差异与训练数据和测试数据的分布有关。这种迁移学习的情况与任何机器学习任务中的一个基本假设有关——假设训练数据和测试数据是i.i.d.。第一个i代表独立,意味着每个样本与其他样本是独立的。在实践中,当存在反馈循环时(如推荐系统中的情况),这一假设可能会被违反。第二部分是i.d.,代表同分布,意味着训练样本和测试样本之间标签及其他特征的分布是相同的。

假设领域是动物照片,任务是识别照片中的猫。这个任务可以建模为一个二分类问题。同分布假设意味着训练样本和测试样本中猫的分布是相似的。这也意味着照片的特征,如分辨率、光照条件和方向等,是非常相似的。实际上,这一假设也常常被违反。

有一个关于早期感知器模型的案例,该模型用于识别树林中的坦克。模型在训练集上表现得相当好。当测试集被扩展时,发现所有的树林中的坦克照片都是在晴天拍摄的,而没有坦克的树林照片则是在阴天拍摄的。

在这种情况下,网络学会了区分晴天和阴天的条件,而非坦克的有无。在测试过程中,提供的图片来自不同的分布,但属于相同的领域,这导致了模型的失败。

处理相似情境的过程被称为领域适应。领域适应有很多技术,其中之一就是数据增强。在计算机视觉中,训练集中的图像可以被裁剪、扭曲或旋转,并可以对其应用不同程度的曝光、对比度或饱和度。这些变换可以增加训练数据量,并且能够减少训练数据和潜在测试数据之间的差距。在语音和音频处理中,也会使用类似的技术,通过向音频样本中添加随机噪声,包括街道声或背景闲聊声来进行增强。领域适应技术在传统机器学习中非常成熟,已有许多相关资源。

然而,迁移学习令人兴奋之处在于,使用来自不同源领域或任务的数据进行预训练,可以在另一个任务或领域上提升模型性能。这个领域有两种迁移学习方式。第一种是多任务学习,第二种是序列学习。

多任务学习

在多任务学习中,不同但相关的任务数据会通过一组共享层进行处理。然后,可能会在顶部添加一些任务特定的层,这些层用于学习特定任务目标的相关信息。图 4.2展示了多任务学习的设置:

一张包含建筑物的图片,自动生成的描述

图 4.2:多任务迁移学习

这些任务特定层的输出将通过不同的损失函数进行评估。所有任务的所有训练样本都会通过模型的所有层进行处理。任务特定层并不期望能够在所有任务上表现良好。期望是共享层能够学习一些不同任务间共享的潜在结构。关于结构的信息提供了有用的信号,并且提升了所有模型的性能。每个任务的数据都有许多特征,但这些特征可以用于构建对其他相关任务有用的表示。

直观上,人们在掌握更复杂技能之前,通常会先学习一些基础技能。学习写字首先需要掌握握笔或铅笔的技巧。写字、绘画和画画可以视为不同的任务,它们共享一个标准的“层”,即握笔或铅笔的技能。同样的概念适用于学习新语言的过程,其中一种语言的结构和语法可能有助于学习相关语言。学习拉丁语系语言(如法语、意大利语和西班牙语)会变得更加轻松,如果你已经掌握了其中一种拉丁语言,因为这些语言共享词根。

多任务学习通过将来自不同任务的数据汇聚在一起,从而增加了可用于训练的数据量。此外,它通过在共享层中尝试学习任务间通用的表示,强制网络更好地进行泛化。

多任务学习是*年来像 GPT-2 和 BERT 这样的模型取得成功的关键原因。它是预训练模型时最常用的技术,之后这些模型可以用于特定任务。

序列学习

序列学习是最常见的迁移学习形式。之所以这样命名,是因为它包含了两个按顺序执行的简单步骤。第一步是预训练,第二步是微调。这些步骤如图 4.3所示:

图 4.3:序列学习

第一步是预训练一个模型。最成功的预训练模型使用某种形式的多任务学习目标,如图中左侧所示。用于预训练的模型的一部分随后会用于图中右侧所示的不同任务。这个可重用的预训练模型部分依赖于具体的架构,可能具有不同的层。图 4.3 中显示的可重用部分仅为示意图。在第二步中,预训练模型被加载并作为任务特定模型的起始层。预训练模型学到的权重可以在任务特定模型训练时被冻结,或者这些权重可以更新或微调。当权重被冻结时,这种使用预训练模型的模式称为特征提取

一般来说,微调相比于特征提取方法会提供更好的性能。然而,这两种方法各有优缺点。在微调中,并非所有权重都会被更新,因为任务特定的训练数据可能较小。如果预训练模型是单词的嵌入,那么其他嵌入可能会变得过时。如果任务的词汇量较小或包含许多词汇外的单词,这可能会影响模型的性能。通常来说,如果源任务和目标任务相似,微调会产生更好的结果。

一个预训练模型的例子是 Word2vec,我们在第一章自然语言处理基础中提到过。还有一个生成词级嵌入的模型叫做GloVe,即全局词表示向量,由斯坦福大学的研究人员于 2014 年提出。接下来,我们将通过在下一节中使用 GloVe 嵌入重新构建 IMDb 电影情感分析,来进行一次实际的迁移学习之旅。之后,我们将探讨 BERT 并在同样的序列学习环境下应用 BERT。

IMDb 情感分析与 GloVe 嵌入

第二章使用 BiLSTM 理解自然语言情感中,构建了一个 BiLSTM 模型来预测 IMDb 电影评论的情感。该模型从头开始学习词的嵌入。该模型在测试集上的准确率为 83.55%,而当前最先进的结果接* 97.4%。如果使用预训练的嵌入,我们预期模型的准确性会有所提升。让我们试试看,并了解迁移学习对这个模型的影响。但首先,我们来了解一下 GloVe 嵌入模型。

GloVe 嵌入

第一章NLP 基础中,我们讨论了基于负采样跳字模型的 Word2Vec 算法。GloVe 模型于 2014 年发布,比 Word2Vec 论文晚了一年。GloVe 和 Word2Vec 模型相似,都是通过周围的单词来确定一个词的嵌入。然而,这些上下文单词的出现频率不同。有些上下文单词在文本中出现的频率高于其他单词。由于这种出现频率的差异,某些词的训练数据可能比其他词更常见。

超过这一部分,Word2Vec 并未以任何方式使用这些共现统计数据。GloVe 考虑了这些频率,并假设共现提供了重要的信息。名称中的Global部分指的是模型在整个语料库中考虑这些共现的事实。GloVe 并不专注于共现的概率,而是专注于考虑探测词的共现比率。

在论文中,作者以蒸汽为例来说明这一概念。假设固体是另一个将用于探测冰和蒸汽关系的词。给定蒸汽时,固体的出现概率为p[solid|steam]。直观上,我们预期这个概率应该很小。相反,固体与冰一起出现的概率表示为p[solid|ice],预计会很大。如果计算 ,我们预期这个值会很显著。如果用气体作为探测词来计算同样的比率,我们会预期相反的行为。如果两者出现的概率相等,不论是由于探测词与之无关,还是与两个词出现的概率相同,那么比率应接* 1。一个同时与冰和蒸汽都相关的探测词是。一个与冰或蒸汽无关的词是时尚。GloVe 确保这种关系被纳入到生成的词嵌入中。它还优化了稀有共现、数值稳定性问题的计算等方面。

现在让我们看看如何使用这些预训练的嵌入来预测情感。第一步是加载数据。这里的代码与第二章使用 BiLSTM 理解自然语言情感中的代码相同;此处提供是为了完整性。

本练习的所有代码都在 GitHub 的chapter4-Xfer-learning-BERT目录中的文件imdb-transfer-learning.ipynb里。

加载 IMDb 训练数据

TensorFlow Datasets 或tfds包将被用于加载数据:

import tensorflow as tf
import tensorflow_datasets as tfds
import numpy as np
import pandas as pd
imdb_train, ds_info = tfds.load(name="imdb_reviews",
                      split="train", 
                      with_info=True, as_supervised=True)
imdb_test = tfds.load(name="imdb_reviews", split="test", 
                      as_supervised=True) 

注意,额外的 50,000 条未标注的评论在本练习中被忽略。训练集和测试集加载完毕后,评论内容需要进行分词和编码:

# Use the default tokenizer settings
tokenizer = tfds.features.text.Tokenizer()
vocabulary_set = set()
MAX_TOKENS = 0
for example, label in imdb_train:
  some_tokens = tokenizer.tokenize(example.numpy())
  if MAX_TOKENS < len(some_tokens):
            MAX_TOKENS = len(some_tokens)
  vocabulary_set.update(some_tokens) 

上面显示的代码对评论文本进行了分词,并构建了一个词汇表。这个词汇表用于构建分词器:

imdb_encoder = tfds.features.text.TokenTextEncoder(vocabulary_set,
                                                   **lowercase=****True**,
                                                   tokenizer=tokenizer)
vocab_size = imdb_encoder.vocab_size
print(vocab_size, MAX_TOKENS) 
93931 2525 

注意,在编码之前,文本已经被转换为小写。转换为小写有助于减少词汇量,并且可能有利于后续查找对应的 GloVe 向量。注意,大写字母可能包含重要信息,这对命名实体识别(NER)等任务有帮助,我们在前面的章节中已涉及过。此外,并非所有语言都区分大写和小写字母。因此,这一转换应在充分考虑后执行。

现在分词器已准备好,数据需要进行分词,并将序列填充到最大长度。由于我们希望与第二章《使用 BiLSTM 理解自然语言中的情感》中训练的模型进行性能比较,因此可以使用相同的设置,即最多采样 150 个评论词汇。以下便捷方法有助于执行此任务:

# transformation functions to be used with the dataset
from tensorflow.keras.preprocessing import sequence
def encode_pad_transform(sample):
    encoded = imdb_encoder.encode(sample.numpy())
    pad = sequence.pad_sequences([encoded], padding='post', 
                                 maxlen=150)
    return np.array(pad[0], dtype=np.int64)  
def encode_tf_fn(sample, label):
    encoded = tf.py_function(encode_pad_transform, 
                                       inp=[sample], 
                                       Tout=(tf.int64))
    encoded.set_shape([None])
    label.set_shape([])
    return encoded, label 

最后,数据使用上面提到的便捷函数进行编码,如下所示:

encoded_train = imdb_train.map(encode_tf_fn,
                      num_parallel_calls=tf.data.experimental.AUTOTUNE)
encoded_test = imdb_test.map(encode_tf_fn,
                      num_parallel_calls=tf.data.experimental.AUTOTUNE) 

此时,所有的训练和测试数据已准备好进行训练。

注意,在限制评论大小时,长评论只会计算前 150 个词。通常,评论的前几句包含上下文或描述,后半部分则是结论。通过限制为评论的前部分,可能会丢失有价值的信息。建议读者尝试一种不同的填充方案,即丢弃评论前半部分的词汇,而不是后半部分,并观察准确度的变化。

下一步是迁移学习中的关键步骤——加载预训练的 GloVe 嵌入,并将其用作嵌入层的权重。

加载预训练的 GloVe 嵌入

首先,需要下载并解压预训练的嵌入:

# Download the GloVe embeddings
!wget http://nlp.stanford.edu/data/glove.6B.zip
!unzip glove.6B.zip
Archive:  glove.6B.zip
  inflating: glove.6B.50d.txt        
  inflating: glove.6B.100d.txt       
  inflating: glove.6B.200d.txt       
  inflating: glove.6B.300d.txt 

注意,这个下载文件很大,超过 800MB,因此执行这一操作可能需要一些时间。解压后,会有四个不同的文件,如上面的输出所示。每个文件包含 40 万个词汇。主要的区别是生成的嵌入维度不同。

在上一章中,模型使用了 64 的嵌入维度。最*的 GloVe 维度是 50,所以我们将使用该维度。文件格式非常简单。每一行文本有多个由空格分隔的值。每行的第一个项目是单词,其余项目是每个维度的向量值。因此,在 50 维的文件中,每一行将有 51 列。这些向量需要加载到内存中:

dict_w2v = {}
with open('glove.6B.50d.txt', "r") as file:
    for line in file:
        tokens = line.split()
        word = tokens[0]
        vector = np.array(tokens[1:], dtype=np.float32)
        if vector.shape[0] == 50:
            dict_w2v[word] = vector
        else:
            print("There was an issue with " + word)
# let's check the vocabulary size
print("Dictionary Size: ", len(dict_w2v)) 
Dictionary Size:  400000 

如果代码正确处理了文件,你应该不会看到任何错误,并且字典大小应为 400,000 个单词。一旦这些向量加载完成,就需要创建一个嵌入矩阵。

使用 GloVe 创建预训练的嵌入矩阵

到目前为止,我们已经有了数据集、它的词汇表以及 GloVe 单词及其对应向量的字典。然而,这两个词汇表之间没有关联。将它们连接起来的方式是通过创建一个嵌入矩阵。首先,我们来初始化一个全零的嵌入矩阵:

embedding_dim = 50
embedding_matrix = np.zeros((imdb_encoder.vocab_size, embedding_dim)) 

请注意,这是一个关键步骤。当使用预训练的词汇表时,无法保证在训练/测试过程中为每个单词找到向量。回想一下之前关于迁移学习的讨论,其中源域和目标域是不同的。差异的一种表现形式是训练数据与预训练模型之间的标记不匹配。随着我们继续进行接下来的步骤,这一点会变得更加明显。

在初始化了这个全零的嵌入矩阵之后,需要对其进行填充。对于评论词汇表中的每个单词,从 GloVe 字典中获取相应的向量。

单词的 ID 是通过编码器获取的,然后将与该条目对应的嵌入矩阵条目设置为获取到的向量:

unk_cnt = 0
unk_set = set()
for word in imdb_encoder.tokens:
    embedding_vector = dict_w2v.get(word)
    if embedding_vector is not None:
        tkn_id = imdb_encoder.encode(word)[0]
        embedding_matrix[tkn_id] = embedding_vector
    else:
        unk_cnt += 1
        unk_set.add(word)
# Print how many weren't found
print("Total unknown words: ", unk_cnt) 
Total unknown words:  14553 

在数据加载步骤中,我们看到总共有 93,931 个标记。其中 14,553 个单词无法找到,大约占标记的 15%。对于这些单词,嵌入矩阵将是零。这是迁移学习的第一步。现在设置已经完成,我们将需要使用 TensorFlow 来使用这些预训练的嵌入向量。将尝试两种不同的模型——第一种基于特征提取,第二种基于微调。

特征提取模型

如前所述,特征提取模型冻结了预训练的权重,并且不更新它们。当前设置中这种方法的一个重要问题是,有大量的标记(超过 14,000 个)没有嵌入向量。这些单词无法与 GloVe 单词列表中的条目匹配。

为了尽量减少预训练词汇表和任务特定词汇表之间未匹配的情况,确保使用相似的分词方案。GloVe 使用的是基于单词的分词方案,类似于斯坦福分词器提供的方案。如第一章所述,自然语言处理基础,这种方法比上述用于训练数据的空格分词器效果更好。我们发现由于不同的分词器,存在 15%的未匹配词汇。作为练习,读者可以实现斯坦福分词器并查看未识别词汇的减少情况。

更新的方法,如 BERT,使用部分子词分词器。子词分词方案可以将单词分解为更小的部分,从而最小化词汇不匹配的机会。一些子词分词方案的例子包括字节对编码BPE)或 WordPiece 分词法。本章中的 BERT 部分更详细地解释了子词分词方案。

如果没有使用预训练的词向量,那么所有单词的向量将几乎从零开始,通过梯度下降进行训练。在这种情况下,词向量已经经过训练,因此我们期望训练会更快。作为基准,BiLSTM 模型在训练词嵌入时,一个周期的训练大约需要 65 秒到 100 秒之间,大多数值大约在 63 秒左右,这是在一台配有 i5 处理器和 Nvidia RTX-2070 GPU 的 Ubuntu 机器上进行的。

现在,让我们构建模型,并将上面生成的嵌入矩阵插入到模型中。需要设置一些基本参数:

# Length of the vocabulary in chars
vocab_size = imdb_encoder.vocab_size # len(chars)
# Number of RNN units
rnn_units = 64
# batch size
BATCH_SIZE=100 

设置一个便捷的函数可以实现快速切换。这个方法使得可以使用相同的架构但不同的超参数来构建模型:

from tensorflow.keras.layers import Embedding, LSTM, \
                                    Bidirectional, Dense

def build_model_bilstm(vocab_size, embedding_dim, 
                       rnn_units, batch_size, **train_emb=****False**):
  model = tf.keras.Sequential([
    Embedding(vocab_size, embedding_dim, mask_zero=True,
              **weights=[embedding_matrix], trainable=train_emb**),
   Bidirectional(LSTM(rnn_units, return_sequences=True, 
                                      dropout=0.5)),
   Bidirectional(LSTM(rnn_units, dropout=0.25)),
   Dense(1, activation='sigmoid')
  ])
  return model 

该模型与上一章中使用的模型完全相同,唯一不同的是上述的高亮代码片段。首先,现在可以传递一个标志来指定是否进一步训练词嵌入或冻结它们。此参数默认设置为 false。第二个变化出现在Embedding层的定义中。一个新参数weights用于将嵌入矩阵作为层的权重加载。在这个参数之后,传递了一个布尔参数trainable,该参数决定在训练过程中该层的权重是否应更新。现在可以像这样创建基于特征提取的模型:

model_fe = build_model_bilstm(
  vocab_size = vocab_size,
  embedding_dim=embedding_dim,
  rnn_units=rnn_units,
  batch_size=BATCH_SIZE)
model_fe.summary() 
Model: "sequential_5"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
embedding_5 (Embedding)      (None, None, 50)          4696550   
_________________________________________________________________
bidirectional_6 (Bidirection (None, None, 128)         58880     
_________________________________________________________________
bidirectional_7 (Bidirection (None, 128)               98816     
_________________________________________________________________
dense_5 (Dense)              (None, 1)                 129       
=================================================================
Total params: 4,854,375
Trainable params: 157,825
Non-trainable params: 4,696,550
_________________________________________________________________ 

该模型大约有 480 万个可训练参数。需要注意的是,这个模型比之前的 BiLSTM 模型小得多,后者有超过 1200 万个参数。一个更简单或更小的模型将训练得更快,并且由于模型容量较小,可能更不容易发生过拟合。

该模型需要使用损失函数、优化器和度量指标进行编译,以便观察模型的进展。二元交叉熵是处理二元分类问题的合适损失函数。Adam 优化器在大多数情况下是一个不错的选择。

自适应矩估计或 Adam 优化器

在深度神经网络训练中的反向传播中,最简单的优化算法是小批量随机梯度下降SGD)。任何预测误差都会反向传播,调整各个单元的权重(即参数)。Adam 是一个方法,解决了 SGD 的一些问题,例如陷入次优局部极小值,以及对每个参数使用相同的学习率。Adam 为每个参数计算自适应学习率,并根据误差以及先前的调整来调整它们。因此,Adam 比其他优化方法收敛得更快,并且被推荐作为默认选择。

将观察到的指标与之前相同,包括准确率、精确率和召回率:

model_fe.compile(loss='binary_crossentropy', 
             optimizer='adam', 
             metrics=['accuracy', 'Precision', 'Recall']) 

在设置好预加载批次后,模型准备进行训练。与之前相似,模型将训练 10 个周期:

# Prefetch for performance
encoded_train_batched = encoded_train.batch(BATCH_SIZE).prefetch(100)
model_fe.fit(encoded_train_batched, epochs=10) 
Epoch 1/10
250/250 [==============================] - 28s 113ms/step - loss: 0.5896 - accuracy: 0.6841 - Precision: 0.6831 - Recall: 0.6870
Epoch 2/10
250/250 [==============================] - 17s 70ms/step - loss: 0.5160 - accuracy: 0.7448 - Precision: 0.7496 - Recall: 0.7354
...
Epoch 9/10
250/250 [==============================] - 17s 70ms/step - loss: 0.4108 - accuracy: 0.8121 - Precision: 0.8126 - Recall: 0.8112
Epoch 10/10
250/250 [==============================] - 17s 70ms/step - loss: 0.4061 - accuracy: 0.8136 - Precision: 0.8147 - Recall: 0.8118 

有几点可以立刻看出。该模型的训练速度显著提高。每个周期大约需要 17 秒,第一个周期最多需要 28 秒。其次,模型没有过拟合。训练集上的最终准确率略高于 81%。在之前的设置中,训练集上的准确率是 99.56%。

还需要注意的是,在第十个周期结束时,准确率仍在上升,仍有很大的提升空间。这表明,继续训练该模型可能会进一步提高准确率。将周期数快速更改为 20 并训练模型,测试集上的准确率达到了 85%以上,精确率为 80%,召回率为 92.8%。

现在,让我们理解这个模型的实用性。为了评估该模型的质量,应该在测试集上评估其表现:

model_fe.evaluate(encoded_test.batch(BATCH_SIZE)) 
250/Unknown - 21s 85ms/step - loss: 0.3999 - accuracy: 0.8282 - Precision: 0.7845 - Recall: 0.9050 

与之前模型在测试集上 83.6%的准确率相比,这个模型的准确率为 82.82%。这个表现相当令人印象深刻,因为该模型的体积仅为前一个模型的 40%,并且训练时间减少了 70%,而准确率仅下降不到 1%。该模型的召回率略高,准确率稍差。这个结果并不完全出乎意料。该模型中有超过 14,000 个零向量!为了解决这个问题,并且尝试基于微调的顺序迁移学习方法,我们来构建一个基于微调的模型。

微调模型

使用便捷函数创建微调模型非常简单。只需要将train_emb参数设置为 true 即可:

model_ft = build_model_bilstm(
  vocab_size=vocab_size,
  embedding_dim=embedding_dim,
  rnn_units=rnn_units,
  batch_size=BATCH_SIZE,
  train_emb=True)
model_ft.summary() 

该模型的大小与特征提取模型相同。然而,由于嵌入向量将会被微调,训练预计会稍微耗时一些。现在可以更新几千个零嵌入。最终的准确率预计会比之前的模型好得多。该模型使用相同的损失函数、优化器和指标进行编译,并训练了 10 个周期:

model_ft.compile(loss='binary_crossentropy', 
             optimizer='adam', 
             metrics=['accuracy', 'Precision', 'Recall'])
model_ft.fit(encoded_train_batched, epochs=10) 
Epoch 1/10
250/250 [==============================] - 35s 139ms/step - loss: 0.5432 - accuracy: 0.7140 - Precision: 0.7153 - Recall: 0.7111
Epoch 2/10
250/250 [==============================] - 24s 96ms/step - loss: 0.3942 - accuracy: 0.8234 - Precision: 0.8274 - Recall: 0.8171
...
Epoch 9/10
250/250 [==============================] - 24s 97ms/step - loss: 0.1303 - accuracy: 0.9521 - Precision: 0.9530 - Recall: 0.9511
Epoch 10/10
250/250 [==============================] - 24s 96ms/step - loss: 0.1132 - accuracy: 0.9580 - Precision: 0.9583 - Recall: 0.9576 

这个准确率非常令人印象深刻,但需要在测试集上验证:

model_ft.evaluate(encoded_test.batch(BATCH_SIZE)) 
250/Unknown - 22s 87ms/step - loss: 0.4624 - accuracy: 0.8710 - Precision: 0.8789 - Recall: 0.8605 

这是我们目前为止取得的最佳结果,准确率为 87.1%。关于数据集的最新成果数据可以通过paperswithcode.com网站获取。具有可重复代码的研究论文会在数据集的排行榜上展示。这个结果在撰写时大约排在paperswithcode.com网站的 SOTA(最新技术)结果中第十七位!

还可以看出,网络有一点过拟合。可以在Embedding层和第一个LSTM层之间添加一个Dropout层,以帮助减少这种过拟合。还需要注意的是,这个网络的训练速度仍然比从头开始训练嵌入要快得多。大多数训练周期仅花费了 24 秒。总体而言,这个模型的体积较小,训练所需时间少,且准确率更高!这就是为什么迁移学习在机器学习和自然语言处理(NLP)中如此重要的原因。

到目前为止,我们已经看到上下文无关的词嵌入的使用。使用这种方法的主要挑战在于,单词根据上下文可能有多重含义。比如单词bank,它既可以指存放钱财和贵重物品的地方,也可以指河岸。在这个领域中,最*的创新是 BERT,它在 2019 年 5 月发布。提高电影评论情感分析准确性的下一步是使用一个预训练的 BERT 模型。下一部分将解释 BERT 模型、其重要的创新以及使用该模型进行当前任务的影响。请注意,BERT 模型非常庞大!如果您的本地计算资源不足,使用带有 GPU 加速器的 Google Colab 是下一部分的一个绝佳选择。

基于 BERT 的迁移学习

像 GloVe 这样的嵌入是上下文无关的嵌入。在自然语言处理的语境下,缺乏上下文可能会有一定的局限性。正如之前讨论的,单词 bank 在不同的上下文中可以有不同的意思。双向编码器表示 来自变换器,或称BERT,是 Google 研究团队于 2019 年 5 月发布的,展示了在基准测试中的显著改进。BERT 模型建立在之前的多个创新之上。BERT 的论文还介绍了 ERT 工作中的几项创新。

使 BERT 成为可能的两个基础性进展是编码器-解码器网络架构和注意力机制。注意力机制进一步发展,产生了变换器架构。变换器架构是 BERT 的基础构建块。这些概念将在接下来的章节中详细介绍。在这两个部分之后,我们将讨论 BERT 模型的具体创新和结构。

编码器-解码器网络

我们已经看到了 LSTM 和 BiLSTM 在将句子建模为单词序列中的应用。这些序列可以具有不同的长度,因为句子的单词数量不同。回想一下在 第二章利用 BiLSTM 理解自然语言中的情感 中,我们讨论了 LSTM 的核心概念是一个按时间展开的单元。对于每个输入符号,LSTM 单元都会生成一个输出。因此,LSTM 产生的输出数量取决于输入符号的数量。所有这些输入符号通过 TimeDistributed() 层结合起来,以供后续网络中的 Dense() 层使用。主要问题在于输入和输出序列的长度是相互关联的。该模型无法有效处理变长序列。因此,输入和输出可能具有不同长度的翻译类任务将无法很好地适应这种架构。

解决这些挑战的方法是在 2014 年由 Ilya Sutskever 等人撰写的论文《Sequence to Sequence Learning with Neural Networks》中提出的。该模型也被称为 seq2seq 模型。

基本思想如下面的图所示:

图 4.4:编码器-解码器网络

该模型分为两个部分——编码器和解码器。一个特殊的符号用于表示输入序列的结束,并附加到输入序列中。请注意,现在输入序列的长度可以是任意的,因为上述图中的这个句子结束符号(EOS)表示输入的结束。在上述图中,输入序列由符号(I[1],I[2],I[3],…)表示。每个输入符号在向量化后传递给 LSTM 模型。输出只从最后一个(EOS)符号收集。编码器 LSTM 网络为(EOS)符号生成的向量是整个输入序列的表示。它可以被看作是整个输入的总结。一个变长序列并没有被转换成固定长度或维度的向量。

这个向量成为解码器层的输入。该模型是自回归的,意味着解码器前一步生成的输出被输入到下一步作为输入。输出生成会持续进行,直到生成特殊的(EOS)符号为止。这种方案允许模型确定输出序列的长度。它打破了输入序列和输出序列长度之间的依赖关系。从概念上讲,这是一个易于理解的模型。然而,这是一个强大的模型。许多任务可以被转化为序列到序列的问题。

一些例子包括将一句话从一种语言翻译成另一种语言、总结一篇文章,其中输入序列是文章的文本,输出序列是摘要,或者是问答问题,其中问题是输入序列,答案是输出序列。语音识别是一个序列到序列的问题,输入序列是 10 毫秒的语音样本,输出是文本。在发布时,这个模型引起了广泛关注,因为它对 Google Translate 的质量产生了巨大的影响。在使用该模型的九个月里,seq2seq 模型背后的团队取得了比 Google Translate 经过 10 多年改进后更高的性能。

伟大的人工智能觉醒

《纽约时报》在 2016 年发布了一篇精彩的文章,标题为上述内容,记录了深度学习的发展历程,特别是关于 seq2seq 论文的作者以及该论文对 Google Translate 质量的巨大影响。这篇文章强烈推荐阅读,能够展示这一架构对自然语言处理(NLP)的变革性影响。文章链接:www.nytimes.com/2016/12/14/magazine/the-great-ai-awakening.html

在掌握这些技术后,下一步的创新便是引入了注意力机制,它允许建模不同 tokens 之间的依赖关系,无论它们之间的距离如何。注意力模型成为了Transformer 模型的基石,后者将在下一节中介绍。

注意力模型

在编码器-解码器模型中,网络的编码器部分创建了输入序列的固定维度表示。随着输入序列长度的增加,越来越多的输入被压缩到这个向量中。通过处理输入 tokens 生成的编码或隐藏状态无法被解码器层访问。编码器状态对解码器是隐藏的。注意力机制使得网络的解码器部分能够看到编码器的隐藏状态。这些隐藏状态在图 4.4中被表示为每个输入 token 的输出(I[1],I[2],I[3],…),但只显示为输入到下一个 token。

在注意力机制中,这些输入 token 的编码也会提供给解码器层。这被称为一般注意力,它指的是输出 tokens 直接依赖于输入 tokens 的编码或隐藏状态的能力。这里的主要创新是解码器基于一系列由编码输入生成的向量进行操作,而不是在输入结束时生成的固定向量。注意力机制使得解码器在解码时能够将注意力集中在编码输入向量的子集上,因此得名。

另一种注意力形式被称为自注意力。自注意力使得不同位置的输入标记之间可以建立连接。如图 4.4中的模型所示,输入标记只看前一个标记的编码。自注意力则使其能够查看前面标记的编码。这两种形式都是对编码器-解码器架构的改进。

虽然有很多种注意力架构,但一种流行的形式被称为巴赫达努注意力。它以该论文的第一作者命名,这篇论文于 2016 年发表,其中提出了这种注意力机制。基于编码器-解码器网络,这种形式使得每个输出状态可以查看编码后的输入并为这些输入学习一些权重。因此,每个输出可以关注不同的输入标记。该模型的示意图如图 4.5所示,这是图 4.4的修改版本:

图 4.5:巴赫达努注意力架构

相较于编码器-解码器架构,注意力机制有两个具体的变化。第一个变化发生在编码器中。此处的编码器层使用的是 BiLSTM(双向长短时记忆网络)。使用 BiLSTM 使得每个单词都能够从它前后的单词中学习。在标准的编码器-解码器架构中使用的是 LSTM,这意味着每个输入单词只能从它之前的单词中学习。

第二个变化与解码器如何使用编码器的输出有关。在之前的架构中,只有最后一个标记的输出,即句子结束标记,才使用整个输入序列的摘要。在巴赫达努注意力(Bahdanau Attention)架构中,每个输入标记的隐藏状态输出会乘以一个对齐权重,该权重表示特定位置的输入标记与目标输出标记之间的匹配程度。通过将每个输入的隐藏状态输出与对应的对齐权重相乘并将所有结果连接起来,可以计算出一个上下文向量。该上下文向量与先前的输出标记一起被输入到输出标记中。

图 4.5展示了这种计算过程,仅针对第二个输出标记。这个对齐模型以及每个输出标记的权重,可以帮助指向在生成该输出标记时最有用的输入标记。请注意,某些细节已被简化以便简洁,详细内容可以在论文中找到。我们将在后续章节中从零开始实现注意力机制。

注意力机制不是一种解释

将对齐分数或注意力权重解读为模型预测特定输出标记的解释是非常诱人的。曾有一篇名为“注意力机制是一种解释”的论文,测试了这一假设。研究的结论是,注意力机制不应被解释为一种解释。即使在相同的输入集上使用不同的注意力权重,也可能会产生相同的输出。

Attention 模型的下一次进展体现在 2017 年的 Transformer 架构中。Transformer 模型是 BERT 架构的核心,所以接下来我们来理解它。

Transformer 模型

Vaswani 等人于 2017 年发表了一篇开创性论文,标题为 Attention Is All You Need。这篇论文奠定了 Transformer 模型的基础,Transformer 模型也成为了许多最*先进模型的核心,比如 ELMo、GPT、GPT-2 和 BERT。Transformer 模型是在 Attention 模型的基础上,通过其关键创新来构建的——使解码器能够看到所有输入的隐藏状态,同时去除其中的递归结构,这样可以避免因处理输入序列的顺序性而导致训练过程缓慢。

Transformer 模型包括编码器和解码器部分。这种编码器-解码器结构使得它在机器翻译类任务中表现最佳。然而,并非所有任务都需要完整的编码器和解码器层。BERT 只使用编码器部分,而像 GPT-2 这样的生成模型则使用解码器部分。本节只讨论架构的编码器部分。下一章将讨论文本生成以及使用 Transformer 解码器的最佳模型。因此,解码器将在那一章中讲解。

什么是语言模型?

语言模型LM)任务通常被定义为预测一个单词序列中的下一个词。语言模型特别适用于文本生成,但在分类任务中效果较差。GPT-2 就是一个符合语言模型定义的例子。这样的模型只会从其左侧出现的词或符号中获取上下文(对于从右至左的语言,则相反)。这种做法在文本生成任务中是合理的。然而,在其他任务中,比如问答或翻译,完整的句子应该是可用的。在这种情况下,使用能够从两侧获取上下文的双向模型是有用的。BERT 就是这样一个模型。它放弃了自回归特性,以便从词或符号的两侧获取上下文信息。

Transformer 的编码器模块由多个子层组成——多头自注意力子层和前馈子层。自注意力子层查看输入序列中的所有词,并生成这些词在彼此上下文中的编码。前馈子层由两层线性变换和中间的 ReLU 激活组成。每个编码器模块由这两个子层组成,而整个编码器则由六个这样的模块构成,如 图 4.6 所示:

一部手机的截图  自动生成的描述

图 4.6:Transformer 编码器架构

在每个编码器块中,都会对多头注意力块和前馈块进行残差连接。当将子层的输出与其接收到的输入相加时,会进行层归一化。这里的主要创新是多头注意力块。共有八个相同的注意力块,其输出被连接起来,生成多头注意力输出。每个注意力块接受编码并定义三个新向量,分别称为查询、键和值向量。这些向量被定义为 64 维,虽然这个维度是一个可以调节的超参数。查询、键和值向量通过训练来学习。

为了理解这个过程,假设输入包含三个标记。每个标记都有一个对应的嵌入。每个标记都会初始化其查询、键和值向量。同时还初始化一个权重向量,当它与输入标记的嵌入相乘时,生成该标记的键。计算出标记的查询向量后,它将与所有输入标记的键向量相乘。需要注意的是,编码器能够访问所有输入,位于每个标记的两侧。因此,已经通过获取相关词的查询向量和输入序列中所有标记的值向量,计算出了一个得分。所有这些得分都会通过软最大化(softmax)处理。结果可以解释为,为了让特定输入标记了解输入中的哪些标记是重要的,提供了一种衡量方式。

从某种程度上讲,相关输入标记会对其他具有高软最大得分的标记保持关注。当输入标记对自身保持关注时,这个得分预期会很高,但它也可以对其他标记保持较高的关注。接下来,这个软最大得分将与每个标记的值向量相乘。然后,将所有这些不同输入标记的值向量加总起来。具有更高软最大得分的标记的值向量将对相关输入标记的输出值向量贡献更大。这完成了在注意力层中计算给定标记的输出。

多头自注意力机制生成查询、键和值向量的多个副本,并使用权重矩阵来计算从输入标记的嵌入中获取查询。论文中提出了八个头,虽然可以对此进行实验。另一个权重矩阵用于将每个头的多个输出结合起来,并将它们连接成一个输出值向量。

这个输出值向量被输入到前馈层,前馈层的输出将传递到下一个编码器块,或者在最后一个编码器块中成为模型的输出。

BERT 核心模型实际上是 Transformer 编码器模型的核心,但引入了一些特定的增强功能,接下来会详细介绍。注意,使用 BERT 模型要容易得多,因为所有这些细节都被抽象化了。然而,了解这些细节可能有助于理解 BERT 的输入和输出。在下一节中将介绍如何使用 BERT 进行 IMDb 情感分析的代码。

BERT(Bidirectional Encoder Representations from Transformers)模型

Transformer 架构的出现是自然语言处理领域的一个重要时刻。这种架构通过几种衍生架构推动了许多创新。BERT 就是这样一种模型。它于 2018 年发布。BERT 模型仅使用 Transformer 架构的编码器部分。编码器的布局与前面描述的相同,包括 12 个编码器块和 12 个注意力头。隐藏层的大小为 768。这组超参数被称为BERT Base。这些超参数导致总模型大小为 1.1 亿参数。还发布了一个更大的模型,其中包括 24 个编码器块、16 个注意力头和隐藏单元大小为 1,024。自论文发布以来,还出现了许多 BERT 的不同变体,如 ALBERT、DistilBERT、RoBERTa、CamemBERT 等等。每个模型都试图通过提高准确性或训练/推理时间来改进 BERT 的性能。

BERT 的预训练方式是独特的。它采用了上面解释的多任务迁移学习原理,用于两个不同的目标进行预训练。第一个目标是Masked Language Model(MLM)任务。在这个任务中,一些输入标记会被随机屏蔽。模型必须根据屏蔽标记的两侧的标记来预测正确的标记。具体来说,输入序列中的一个标记在 80%的情况下会被特殊的[MASK]标记替换。在 10%的情况下,选择的标记会被词汇表中的另一个随机标记替换。在最后的 10%的情况下,标记保持不变。此方案的结果是模型无法依赖于特定的标记存在,并被迫根据给定标记之前和之后的标记的分布学习上下文表示。如果没有这种屏蔽,模型的双向性质意味着每个单词能间接地从任何方向看到自己,这将使得预测目标标记的任务变得非常容易。

模型预训练的第二个目标是 下一句预测NSP)。这里的直觉是,许多自然语言处理任务都涉及一对句子。例如,一个问答问题可以将问题建模为第一句,而用于回答问题的段落则成为第二句。模型的输出可能是一个跨度标识符,用于识别段落中作为问题答案的开始和结束标记索引。在句子相似性或释义任务中,可以将两个句子对传入模型,得到一个相似性分数。NSP 模型通过传入带有二元标签的句子对进行训练,标签指示第二句是否跟随第一句。在 50% 的训练样本中,传入的是真正的后续句子,并带有标签 IsNext,而在另外 50% 的样本中,传入的是随机句子,并带有标签 NotNext

BERT 还解决了我们在上面 GloVe 示例中看到的一个问题——词汇表外的标记。大约 15% 的标记不在词汇表中。为了解决这个问题,BERT 使用了 WordPiece 分词方案,词汇表大小为 30,000 个标记。请注意,这个词汇表比 GloVe 的词汇表要小得多。WordPiece 属于一种叫做 子词 分词的类别。这个类别的其他成员包括 字节对编码BPE)、SentencePiece 和 unigram 语言模型。WordPiece 模型的灵感来自于 Google 翻译团队在处理日语和韩语文本时的工作。如果你记得第一章中关于分词的讨论,我们曾提到日语不使用空格来分隔单词。因此,很难将其分词成单词。为这类语言创建词汇表的方法对于像英语这样的语言也非常有用,可以保持词典大小在合理范围内。

以德语翻译 Life Insurance Company 这个词组为例,它将被翻译为 Lebensversicherungsgesellschaft。同样,Gross Domestic Product 将翻译为 Bruttoinlandsprodukt。如果将单词直接作为词汇,那么词汇表的大小将非常庞大。采用子词方法可以更高效地表示这些单词。

更小的字典可以减少训练时间和内存需求。如果较小的字典不以牺牲词汇外标记为代价,那么它非常有用。为了帮助理解子词标记化的概念,可以考虑一个极端的例子,其中标记化将单词拆分为单个字符和数字。这个词汇表的大小将是 37——包括 26 个字母、10 个数字和空格。一个子词标记化方案的例子是引入两个新标记,* -ing -tion*。每个以这两个标记结尾的单词都可以被拆分为两个子词——后缀之前的部分和这两个后缀之一。通过对语言语法和结构的理解,可以使用词干提取和词形还原等技术来实现这一点。BERT 使用的 WordPiece 标记化方法基于 BPE。在 BPE 中,第一步是定义目标词汇表大小。

接下来,整个文本被转换成仅由单个字符标记组成的词汇表,并映射到出现频率。然后,对此进行多次遍历,将标记对组合在一起,以最大化生成的二元组的频率。对于每个生成的子词,添加一个特殊标记来表示单词的结束,以便进行去标记化处理。此外,如果子词不是单词的开头,则添加##标签,以帮助重建原始单词。这个过程会一直进行,直到达到所需的词汇量,或者达到标记的最小频率条件(频率为 1)。BPE 最大化频率,而 WordPiece 则在此基础上增加了另一个目标。

WordPiece 的目标包括通过考虑被合并标记的频率以及合并后的二元组的频率来增加互信息。这对模型做出了细微调整。Facebook 的 RoBERTa 尝试使用 BPE 模型,但并未看到性能上有显著差异。GPT-2 生成模型基于 BPE 模型。

以 IMDB 数据集为例,以下是一个示例句子:

This was an absolutely terrible movie. Don't be `lured` in by Christopher Walken or Michael Ironside. 

使用 BERT 进行标记化后,它看起来像这样:

[CLS] This was an absolutely terrible movie . Don' t be `lure ##d` in by Christopher Walk ##en or Michael Iron ##side . [SEP] 

[CLS][SEP]是特殊的标记,它们将在稍后介绍。注意,由此产生的单词lured被拆分的方式。现在我们理解了 BERT 模型的基本结构,接下来让我们尝试用它进行 IMDB 情感分类问题的迁移学习。第一步是准备数据。

所有 BERT 实现的代码可以在本章的 GitHub 文件夹中的imdb-transfer-learning.ipynb笔记本中找到,位于基于 BERT 的迁移学习部分。请运行标题为加载 IMDB 训练数据的代码段,以确保在继续之前数据已被加载。

使用 BERT 进行标记化和规范化

在阅读了 BERT 模型的描述后,你可能会为实现代码而感到紧张。但不要害怕,Hugging Face 的朋友们已经提供了预训练模型和抽象化接口,使得使用像 BERT 这样先进的模型变得轻松。让 BERT 正常工作的通用流程是:

  1. 加载预训练模型

  2. 实例化分词器并对数据进行分词

  3. 设置模型并进行编译

  4. 将模型应用于数据

这些步骤每个都不会超过几行代码。所以让我们开始吧。第一步是安装 Hugging Face 库:

!pip install transformers==3.0.2 

分词器是第一步——在使用之前需要导入它:

from transformers import BertTokenizer
bert_name = 'bert-base-cased'
tokenizer = BertTokenizer.from_pretrained(bert_name, 
                                          add_special_tokens=True, 
                                          do_lower_case=False,
                                          max_length=150,
                                          pad_to_max_length=True) 

这就是加载预训练分词器的全部内容!在上面的代码中有几点需要注意。首先,Hugging Face 发布了多个可供下载的模型。完整的模型和名称列表可以在huggingface.co/transformers/pretrained_models.html找到。以下是一些可用的关键 BERT 模型:

模型名称 描述
bert-base-uncased / bert-base-cased 基础 BERT 模型的变体,具有 12 层编码器,768 个隐藏单元和 12 个注意力头,总参数量约为 1.1 亿。唯一的区别是输入是大小写敏感的还是全小写的。
bert-large-uncased / bert-large-cased 该模型具有 24 层编码器,1,024 个隐藏单元和 16 个注意力头,总参数量约为 3.4 亿。大小写模型的分割方式相似。
bert-base-multilingual-cased 该模型的参数与上面的bert-base-cased相同,训练于 104 种语言,并使用了最大的维基百科条目。然而,不建议在国际语言中使用无大小写版本,尽管该模型是可用的。
bert-base-cased-finetuned-mrpc 该模型已在微软研究的释义识别任务上进行微调,专注于新闻领域的同义句识别。
bert-base-japanese 与基础模型相同大小,但训练于日语文本。注意,这里使用了 MeCab 和 WordPiece 分词器。
bert-base-chinese 与基础模型相同大小,但训练于简体中文和繁体中文的大小写文本。

左侧的任何值都可以在上面的bert_name变量中使用,以加载适当的分词器。上面代码的第二行从云端下载配置和词汇文件,并实例化分词器。该加载器需要多个参数。由于我们使用的是一个大小写敏感的英文模型,因此我们不希望分词器将单词转换为小写,这是由do_lower_case参数指定的。请注意,默认值为True。输入句子将被分词,最多为 150 个标记,就像我们在 GloVe 模型中看到的那样。pad_to_max_length进一步表明,分词器也应填充它生成的序列。

第一个参数add_special_tokens需要一些解释。在到目前为止的示例中,我们处理了一个序列和一个最大长度。如果序列短于该最大长度,则会用一个特殊的填充令牌进行填充。然而,由于下一句预测任务的预训练,BERT 有一种特殊的方式来编码它的序列。它需要一种方式来提供两个序列作为输入。在分类任务中,比如 IMDb 情感预测,第二个序列只是留空。BERT 模型需要提供三种序列:

  • input_ids:这对应于输入中的令牌,这些令牌被转换为 ID。这就是我们在其他示例中一直在做的事情。在 IMDb 示例中,我们只有一个序列。然而,如果问题需要传入两个序列,那么一个特殊的令牌[SEP]将被添加到两个序列之间。[SEP]是由分词器添加的一个特殊令牌的示例。另一个特殊令牌[CLS]则会被附加到输入的开始位置。[CLS]代表分类器令牌。在分类问题的情况下,这个令牌的嵌入可以被视为输入的摘要,BERT 模型上方的额外层会使用这个令牌。也可以使用所有输入的嵌入之和作为一种替代方案。

  • token_type_ids:如果输入包含两个序列,例如在问答问题中,那么这些 ID 告诉模型哪个input_ids对应于哪个序列。在一些文本中,这被称为段标识符。第一个序列是第一个段,第二个序列是第二个段。

  • attention_mask:由于序列被填充,这个掩码告诉模型实际的令牌在哪里结束,从而确保注意力计算时不使用填充令牌。

由于 BERT 可以接受两个序列作为输入,理解填充是至关重要的,因为当提供一对序列时,如何在最大序列长度的上下文中处理填充可能会引起混淆。最大序列长度指的是一对序列的组合长度。如果组合长度超过最大长度,有三种不同的截断方式。前两种方式是从第一个或第二个序列减少长度。第三种方式是从最长的序列开始截断,一次去除一个令牌,直到两者的长度最多只差一个。在构造函数中,可以通过传递truncation_strategy参数,并设置其值为only_firstonly_secondlongest_first来配置此行为。

图 4.7展示了如何将一个输入序列转换为上面列出的三个输入序列:

A close up of a keyboard  Description automatically generated

图 4.7:将输入映射到 BERT 序列

如果输入序列是 Don't be lured,那么上图显示了如何使用 WordPiece 分词器对其进行分词,并添加了特殊标记。上述示例设置了最大序列长度为九个标记。只提供了一个序列,因此令牌类型 ID 或段 ID 都具有相同的值。注意力掩码设置为 1,其中对应的令牌条目是真实令牌。以下代码用于生成这些编码:

tokenizer.encode_plus(" Don't be lured", add_special_tokens=True, 
                      max_length=9,
                      pad_to_max_length=True, 
                      return_attention_mask=True, 
                      return_token_type_ids=True) 
{'input_ids': [101, 1790, 112, 189, 1129, 19615, 1181, 102, 0], 'token_type_ids': [0, 0, 0, 0, 0, 0, 0, 0, 0], 'attention_mask': [1, 1, 1, 1, 1, 1, 1, 1, 0]} 

即使在本章中我们不会使用一对序列,了解当传入一对序列时,编码的样子也是很有用的。如果传入两个字符串,它们将被视为一对。这在下面的代码中有展示:

tokenizer.encode_plus(" Don't be"," lured", add_special_tokens=True, 
                      max_length=10,
                      pad_to_max_length=True, 
                      return_attention_mask=True, 
                      return_token_type_ids=True) 
{'input_ids': [101, 1790, 112, 189, 1129, **102**, 19615, 1181, **102**, 0], 'token_type_ids': [0, 0, 0, 0, 0, 0, **1, 1, 1**, 0], 'attention_mask': [1, 1, 1, 1, 1, 1, 1, 1, 1, 0]} 

输入 ID 有两个分隔符,以区分两个序列。令牌类型 ID 有助于区分哪些令牌对应于哪个序列。请注意,填充令牌的令牌类型 ID 被设置为 0。在网络中,它从不被使用,因为所有值都会与注意力掩码相乘。

为了对所有 IMDb 评论的输入进行编码,定义了一个辅助函数,如下所示:

def bert_encoder(review):
    txt = review.numpy().decode('utf-8')
    encoded = tokenizer.encode_plus(txt, add_special_tokens=True, 
                                    max_length=150, 
                                    pad_to_max_length=True, 
                                    return_attention_mask=True, 
                                    return_token_type_ids=True)
    return encoded['input_ids'], encoded['token_type_ids'], \
           encoded['attention_mask'] 

这个方法相当简单。它接受输入张量并使用 UTF-8 解码。通过分词器,将输入转换为三个序列。

这是一个很好的机会,可以实现一种不同的填充算法。例如,实现一个算法,取最后 150 个令牌,而不是前 150 个,并比较两种方法的性能。

现在,这需要应用于训练数据中的每一条评论:

bert_train = [bert_encoder(r) for r, l in imdb_train]
bert_lbl = [l for r, l in imdb_train]
bert_train = np.array(bert_train)
bert_lbl = tf.keras.utils.to_categorical(bert_lbl, num_classes=2) 

评论的标签也被转换为分类值。使用 sklearn 包,训练数据被拆分为训练集和验证集:

# create training and validation splits
from sklearn.model_selection import train_test_split
x_train, x_val, y_train, y_val = train_test_split(bert_train, 
                                         bert_lbl,
                                         test_size=0.2, 
                                         random_state=42)
print(x_train.shape, y_train.shape) 
(20000, 3, 150) (20000, 2) 

需要做一些额外的数据处理,将输入转换成三个输入字典,并在 tf.DataSet 中使用,以便在训练中轻松使用:

tr_reviews, tr_segments, tr_masks = np.split(x_train, 3, axis=1)
val_reviews, val_segments, val_masks = np.split(x_val, 3, axis=1)
tr_reviews = tr_reviews.squeeze()
tr_segments = tr_segments.squeeze()
tr_masks = tr_masks.squeeze()
val_reviews = val_reviews.squeeze()
val_segments = val_segments.squeeze()
val_masks = val_masks.squeeze() 

这些训练和验证序列被转换成数据集,如下所示:

def example_to_features(input_ids,attention_masks,token_type_ids,y):
  return {"input_ids": input_ids,
          "attention_mask": attention_masks,
          "token_type_ids": token_type_ids},y
train_ds = tf.data.Dataset.from_tensor_slices((tr_reviews, 
tr_masks, tr_segments, y_train)).\
            map(example_to_features).shuffle(100).batch(16)
valid_ds = tf.data.Dataset.from_tensor_slices((val_reviews, 
val_masks, val_segments, y_val)).\
            map(example_to_features).shuffle(100).batch(16) 

这里使用了批处理大小为 16。GPU 的内存是这里的限制因素。Google Colab 支持的批处理长度为 32。一个 8GB 内存的 GPU 支持的批处理大小为 16。现在,我们准备好使用 BERT 进行分类训练模型。我们将看到两种方法。第一种方法将使用一个预构建的分类模型在 BERT 之上。这将在下一个部分中展示。第二种方法将使用基础的 BERT 模型,并在其上添加自定义层来完成相同的任务。这个技术将在后面的部分中演示。

预构建的 BERT 分类模型

Hugging Face 库通过提供一个类,使得使用预构建的 BERT 模型进行分类变得非常简单:

from transformers import TFBertForSequenceClassification
bert_model = TFBertForSequenceClassification.from_pretrained(bert_name) 

这相当简单,不是吗?请注意,模型的实例化将需要从云端下载模型。然而,如果代码是在本地或专用机器上运行的,这些模型会被缓存到本地机器上。在 Google Colab 环境中,每次初始化 Colab 实例时都会执行此下载。要使用此模型,我们只需提供优化器和损失函数并编译模型:

optimizer = tf.keras.optimizers.Aadam(learning_rate=2e-5)
loss = tf.keras.losses.BinaryCrossentropy(from_logits=True)
bert_model.compile(optimizer=optimizer, loss=loss, metrics=['accuracy']) 

这个模型实际上在结构上相当简单,正如其摘要所示:

bert_model.summary() 
Model: "tf_bert_for_sequence_classification_7"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
bert (TFBertMainLayer)       multiple                  108310272 
_________________________________________________________________
dropout_303 (Dropout)        multiple                  0         
_________________________________________________________________
classifier (Dense)           multiple                  1538      
=================================================================
Total params: 108,311,810
Trainable params: 108,311,810
Non-trainable params: 0
_________________________________________________________________ 

所以,模型包含整个 BERT 模型,一个丢弃层,以及顶部的分类器层。这是最简化的版本。

BERT 论文建议了一些微调设置。它们建议批次大小为 16 或 32,训练 2 到 4 个周期。此外,它们还建议使用以下 Adam 优化器的学习率之一:5e-5、3e-5 或 2e-5。模型在你的环境中启动并运行后,欢迎使用不同的设置进行训练,看看对准确度的影响。

在上一节中,我们将数据分批为 16 个一组。这里,Adam 优化器配置为使用 2e-5 的学习率。我们将训练此模型 3 个周期。请注意,训练将会非常慢:

print("Fine-tuning BERT on IMDB")
bert_history = bert_model.fit(train_ds, epochs=3, 
                              validation_data=valid_ds) 
Fine-tuning BERT on IMDB
Train for 1250 steps, validate for 313 steps
Epoch 1/3
1250/1250 [==============================] - 480s 384ms/step - loss: 0.3567 - accuracy: 0.8320 - val_loss: 0.2654 - val_accuracy: 0.8813
Epoch 2/3
1250/1250 [==============================] - 469s 375ms/step - loss: 0.2009 - accuracy: 0.9188 - val_loss: 0.3571 - val_accuracy: 0.8576
Epoch 3/3
1250/1250 [==============================] - 470s 376ms/step - loss: 0.1056 - accuracy: 0.9613 - val_loss: 0.3387 - val_accuracy: 0.8883 

如果在测试集上能够保持这个验证准确度,那么我们所做的工作就相当值得称赞。接下来需要进行这一验证。使用上一节中的便捷方法,测试数据将被标记化并编码为正确的格式:

# prep data for testing
bert_test = [bert_encoder(r) for r,l in imdb_test]
bert_tst_lbl = [l for r, l in imdb_test]
bert_test2 = np.array(bert_test)
bert_tst_lbl2 = tf.keras.utils.to_categorical (bert_tst_lbl,                                                num_classes=2)
ts_reviews, ts_segments, ts_masks = np.split(bert_test2, 3, axis=1)
ts_reviews = ts_reviews.squeeze()
ts_segments = ts_segments.squeeze()
ts_masks = ts_masks.squeeze()
test_ds = tf.data.Dataset.from_tensor_slices((ts_reviews, 
                    ts_masks, ts_segments, bert_tst_lbl2)).\
            map(example_to_features).shuffle(100).batch(16) 

在测试数据集上评估该模型的表现,得到如下结果:

bert_model.evaluate(test_ds) 
1563/1563 [==============================] - 202s 129ms/step - loss: 0.3647 - accuracy: 0.8799
[0.3646871318983454, 0.8799] 

该模型的准确度几乎达到了 88%!这比之前展示的最佳 GloVe 模型还要高,而且实现的代码要少得多。

在下一节中,我们将尝试在 BERT 模型之上构建自定义层,进一步提升迁移学习的水*。

自定义 BERT 模型

BERT 模型为所有输入的标记输出上下文嵌入。通常,[CLS]标记对应的嵌入用于分类任务,代表整个文档。Hugging Face 提供的预构建模型返回整个序列的嵌入,以及这个池化输出,它表示整个文档作为模型的输出。这个池化输出向量可以在未来的层中用于帮助分类任务。这就是我们构建客户模型时将采取的方法。

本节的代码位于同一笔记本的客户模型与 BERT标题下。

本次探索的起点是基础的TFBertModel。可以这样导入并实例化:

from transformers import TFBertModel
bert_name = 'bert-base-cased'
bert = TFBertModel.from_pretrained(bert_name) 
bert.summary() 
Model: "tf_bert_model"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
bert (TFBertMainLayer)       multiple                  108310272 
=================================================================
Total params: 108,310,272
Trainable params: 108,310,272
Non-trainable params: 0
_________________________________________________________________ 

由于我们使用的是相同的预训练模型,即大小写敏感的 BERT-Base 模型,因此可以复用上一节中的标记化和准备好的数据。如果你还没有操作,花点时间确保已经运行了使用 BERT 的标记化和标准化这一节中的代码来准备数据。

现在,需要定义自定义模型。该模型的第一层是 BERT 层。此层将接受三个输入,分别是输入标记、注意力掩码和标记类型 ID:

max_seq_len = 150
inp_ids = tf.keras.layers.Input((max_seq_len,), dtype=tf.int64, name="input_ids")
att_mask = tf.keras.layers.Input((max_seq_len,), dtype=tf.int64, name="attention_mask")
seg_ids = tf.keras.layers.Input((max_seq_len,), dtype=tf.int64, name="token_type_ids") 

这些名称需要与训练和测试数据集中定义的字典匹配。可以通过打印数据集的规范来检查这一点:

train_ds.element_spec 
({'input_ids': TensorSpec(shape=(None, 150), dtype=tf.int64, name=None),
  'attention_mask': TensorSpec(shape=(None, 150), dtype=tf.int64, name=None),
  'token_type_ids': TensorSpec(shape=(None, 150), dtype=tf.int64, name=None)},
 TensorSpec(shape=(None, 2), dtype=tf.float32, name=None)) 

BERT 模型期望这些输入以字典形式传递。它也可以接受作为命名参数的输入,但这种方式更加清晰,并且有助于追踪输入。一旦输入映射完成,就可以计算 BERT 模型的输出:

inp_dict = {"input_ids": inp_ids,
            "attention_mask": att_mask,
            "token_type_ids": seg_ids}
outputs = bert(inp_dict)
# let's see the output structure
outputs 
(<tf.Tensor 'tf_bert_model_3/Identity:0' shape=(None, 150, 768) dtype=float32>,
 <tf.Tensor 'tf_bert_model_3/Identity_1:0' shape=(None, 768) dtype=float32>) 

第一个输出包含每个输入标记的嵌入,包括特殊标记 [CLS][SEP]。第二个输出对应于 [CLS] 标记的输出。该输出将在模型中进一步使用:

x = tf.keras.layers.Dropout(0.2)(outputs[1])
x = tf.keras.layers.Dense(200, activation='relu')(x)
x = tf.keras.layers.Dropout(0.2)(x)
x = tf.keras.layers.Dense(2, activation='sigmoid')(x)
custom_model = tf.keras.models.Model(inputs=inp_dict, outputs=x) 

上述模型仅用于演示这项技术。我们在输出层之前添加了一个密集层和几个丢弃层。现在,客户模型已经准备好进行训练。该模型需要用优化器、损失函数和指标来编译:

optimizer = tf.keras.optimizers.Adam(learning_rate=2e-5)
loss = tf.keras.losses.BinaryCrossentropy(from_logits=True)
custom_model.compile(optimizer=optimizer, loss=loss, metrics=['accuracy']) 

这是这个模型的样子:

custom_model.summary() 

A screenshot of a cell phone  Description automatically generated

这个自定义模型在 BERT 参数基础上增加了 154,202 个可训练参数。该模型已经准备好进行训练。我们将使用与之前 BERT 部分相同的设置,并将模型训练 3 个周期:

print("Custom Model: Fine-tuning BERT on IMDB")
custom_history = custom_model.fit(train_ds, epochs=3, 
                                  validation_data=valid_ds) 
Custom Model: Fine-tuning BERT on IMDB
Train for 1250 steps, validate for 313 steps
Epoch 1/3
1250/1250 [==============================] - 477s 381ms/step - loss: 0.5912 - accuracy: 0.8069 - val_loss: 0.6009 - val_accuracy: 0.8020
Epoch 2/3
1250/1250 [==============================] - 469s 375ms/step - loss: 0.5696 - accuracy: 0.8570 - val_loss: 0.5643 - val_accuracy: 0.8646
Epoch 3/3
1250/1250 [==============================] - 470s 376ms/step - loss: 0.5559 - accuracy: 0.8883 - val_loss: 0.5647 - val_accuracy: 0.8669 

在测试集上评估得到 86.29% 的准确率。请注意,这里使用的预训练 BERT 模型部分中的测试数据编码步骤也在此处使用:

custom_model.evaluate(test_ds) 
1563/1563 [==============================] - 201s 128ms/step - loss: 0.5667 - accuracy: 0.8629 

BERT 的微调通常在较少的周期内进行,并使用较小的 Adam 学习率。如果进行大量微调,则存在 BERT 忘记其预训练参数的风险。在构建自定义模型时,这可能会成为一个限制,因为几个周期可能不足以训练添加的层。在这种情况下,可以冻结 BERT 模型层,并继续进一步训练。冻结 BERT 层相对简单,但需要重新编译模型:

bert.trainable = False                  # don't train BERT any more
optimizer = tf.keras.optimizers.Adam()  # standard learning rate
custom_model.compile(optimizer=optimizer, loss=loss, metrics=['accuracy']) 

我们可以检查模型摘要,以验证可训练参数的数量已经改变,以反映 BERT 层被冻结:

custom_model.summary() 

A screenshot of a cell phone  Description automatically generated

图 4.8:模型摘要

我们可以看到,现在所有的 BERT 参数都已设置为不可训练。由于模型正在重新编译,我们也趁机修改了学习率。

在训练过程中改变序列长度和学习率是 TensorFlow 中的高级技术。BERT 模型最初使用了 128 作为序列长度,并在后期训练中将其改为 512。通常,在训练的前几个周期中,学习率会逐步增加,然后随着训练的进行而下降。

现在,可以像这样继续训练多个周期:

print("Custom Model: Keep training custom model on IMDB")
custom_history = custom_model.fit(train_ds, epochs=10, 
                                  validation_data=valid_ds) 

为了简洁起见,未显示训练输出。对测试集进行检查时,模型准确率为 86.96%:

custom_model.evaluate(test_ds) 
1563/1563 [==============================] - 195s 125ms/step - loss: 0.5657 - accuracy: 0.8696 

如果你在思考这个自定义模型的准确率是否低于预训练模型,这是一个值得深思的问题。更大的网络并不总是更好,过度训练可能会导致模型性能下降,因为过拟合是其中的一个原因。你可以尝试在自定义模型中使用所有输入标记的输出编码,将它们传递到 LSTM 层,或者将它们连接起来通过全连接层然后进行预测。

在完成了 Transformer 架构的编码器部分的学习后,我们准备深入探讨该架构的解码器部分,它用于文本生成。这将是下一章的重点。在此之前,让我们回顾一下本章所涵盖的所有内容。

总结

迁移学习在 NLP 领域取得了很大的进展,在这个领域中,数据易于获取,但标签数据是一个挑战。我们首先介绍了不同类型的迁移学习。然后,我们将预训练的 GloVe 嵌入应用于 IMDb 情感分析问题,看到一个训练时间更短、规模更小的模型也能得到相当的准确率。

接下来,我们了解了 NLP 模型演变中的关键时刻,从编码器-解码器架构、注意力机制和 Transformer 模型开始,然后了解了 BERT 模型。使用 Hugging Face 库,我们使用了一个预训练的 BERT 模型,并基于 BERT 构建了一个自定义模型,用于 IMDb 评论的情感分类。

BERT 仅使用 Transformer 模型的编码器部分。堆栈的解码器部分用于文本生成。接下来的两章将重点完成对 Transformer 模型的理解。下一章将使用堆栈的解码器部分进行文本生成和句子补全。接下来的章节将使用完整的编码器-解码器网络架构进行文本摘要。

到目前为止,我们已经为模型中的标记训练了嵌入。通过使用预训练的嵌入,可以获得相当大的提升。下一章将重点介绍迁移学习的概念以及使用像 BERT 这样的预训练嵌入。

第五章:使用 RNN 和 GPT-2 生成文本

当你的手机在你输入消息时自动完成一个单词,或者 Gmail 在你回复邮件时建议简短回复或自动完成一句话时,背景中正在运行一个文本生成模型。Transformer 架构构成了最先进的文本生成模型的基础。如前一章所述,BERT 仅使用 Transformer 架构的编码器部分。

然而,BERT 是双向的,不适合用于文本生成。基于 Transformer 架构解码器部分的从左到右(或从右到左,取决于语言)语言模型是当今文本生成模型的基础。

文本可以按字符逐个生成,也可以将单词和句子一起生成。这两种方法将在本章中展示。具体来说,我们将涵盖以下主题:

  • 使用以下方法生成文本:

    • 使用基于字符的 RNN 生成新闻标题和完成文本消息

    • 使用 GPT-2 生成完整的句子

  • 使用以下技术提高文本生成质量:

    • 贪婪搜索

    • Beam 搜索

    • Top-K 采样

  • 使用学习率退火和检查点等高级技术来支持较长的训练时间:

  • Transformer 解码器架构的详细信息

  • GPT 和 GPT-2 模型的详细信息

首先展示的是基于字符的文本生成方法。例如,这种模型在消息*台中生成部分输入单词的补全时非常有用。

逐字符生成文本

文本生成提供了一个窗口,帮助我们了解深度学习模型是否在学习语言的潜在结构。本章中将使用两种不同的方法生成文本。第一种方法是基于 RNN 的模型,它一次生成一个字符。

在前几章中,我们已经看到基于单词和子词的不同分词方法。文本被分解为字符,包括大写字母、小写字母、标点符号和数字。总共有 96 个标记。这个分词方法是一个极端的示例,用来测试模型能在多大程度上学习语言的结构。模型将被训练预测基于给定字符集的下一个字符。如果语言中确实存在某种潜在结构,模型应该能够识别并生成合理的句子。

一次生成一个字符的连贯句子是一项非常具有挑战性的任务。该模型没有字典或词汇表,也不具备名词大写或任何语法规则的概念。然而,我们仍然期望它能生成看起来合理的句子。单词的结构及其在句子中的顺序并非随机,而是受到语言语法规则的驱动。单词具有某种结构,基于词性和词根。基于字符的模型拥有最小的词汇表,但我们希望模型能学到大量关于字母使用的知识。这可能看起来是个艰巨的任务,但请准备好被惊讶。让我们从数据加载和预处理步骤开始。

数据加载和预处理

对于这个特定的示例,我们将使用来自受限领域的数据——一组新闻标题。假设新闻标题通常较短,并遵循特定的结构。这些标题通常是文章的摘要,并包含大量专有名词,例如公司名称和名人姓名。对于这个特定任务,来自两个不同数据集的数据被合并在一起使用。第一个数据集叫做新闻聚合器数据集,由意大利罗马三大学工程学院人工智能实验室生成。加利福尼亚大学欧文分校提供了该数据集的下载链接:archive.ics.uci.edu/ml/datasets/News+Aggregator。该数据集包含超过 420,000 条新闻文章标题、URL 和其他信息。第二个数据集是来自《赫芬顿邮报》的 200,000 多篇新闻文章,名为新闻类别数据集,由 Rishabh Mishra 收集,并在 Kaggle 上发布:www.kaggle.com/rmisra/news-category-dataset

来自两个数据集的新闻文章标题已被提取并编译成一个文件。此步骤已完成,以节省时间。压缩后的输出文件名为news-headlines.tsv.zip,并位于与本章对应的chapter5-nlg-with-transformer-gpt/char-rnn GitHub 文件夹中。该文件夹位于本书的 GitHub 仓库内。该文件的格式非常简单,包含两列,通过制表符分隔。第一列是原始标题,第二列是该标题的小写版本。本示例仅使用文件的第一列。

但是,你可以尝试无大小写版本,看看结果有何不同。训练这类模型通常需要很长时间,往往是几个小时。在 IPython 笔记本中训练可能很困难,因为会遇到很多问题,比如与内核失去连接或内核进程崩溃,可能导致已训练的模型丢失。在本例中,我们尝试做的事情类似于从零开始训练 BERT。别担心;我们训练模型的时间要比训练 BERT 的时间短得多。长时间的训练循环存在崩溃的风险。如果发生这种情况,我们不想从头开始重新训练。训练过程中模型会频繁保存检查点,以便在发生故障时可以从最后一个检查点恢复模型状态。然后,可以从最后一个检查点重新开始训练。从命令行执行的 Python 文件在运行长时间训练循环时提供了最大的控制权。

本示例中展示的命令行指令已在 Ubuntu 18.04 LTS 机器上进行了测试。这些命令应在 macOS 命令行上直接工作,但可能需要进行一些调整。Windows 用户可能需要将这些命令翻译为适合他们操作系统的版本。Windows 10 的高级用户应该能够使用 Windows 子系统 Linux (WSL) 功能来执行相同的命令。

回到数据格式,加载数据所需做的只是解压准备好的标题文件。导航到从 GitHub 下载的 ZIP 文件所在的文件夹。可以解压并检查该压缩文件中的标题:

$ unzip news-headlines.tsv.zip
Archive:  news-headlines.tsv.zip
  inflating: news-headlines.tsv 

让我们检查一下文件的内容,以便了解数据的概况:

$ head -3 news-headlines.tsv
There Were 2 Mass Shootings In Texas Last Week, But Only 1 On TV there were 2 mass shootings in texas last week, but only 1 on tv
Will Smith Joins Diplo And Nicky Jam For The 2018 World Cup's Official Song will smith joins diplo and nicky jam for the 2018 world cup's official song
Hugh Grant Marries For The First Time At Age 57 hugh grant marries for the first time at age 57 

该模型是在上述标题的基础上进行训练的。我们准备好进入下一步,加载文件以执行归一化和标记化操作。

数据归一化和标记化

如上所述,该模型使用每个字符作为一个标记。因此,每个字母,包括标点符号、数字和空格,都变成一个标记。额外增加了三个标记,它们是:

  • <EOS>:表示句子的结束。该模型可以使用此标记表示文本生成已完成。所有标题都会以此标记结尾。

  • <UNK>:虽然这是一个基于字符的模型,但数据集中可能包含其他语言或字符集的不同字符。当检测到一个不在我们 96 个字符集中的字符时,会使用此标记。这种方法与基于词汇的词汇表方法一致,在这种方法中,通常会用一个特殊的标记替换词汇表之外的词汇。

  • <PAD>:这是一个独特的填充标记,用于将所有标题填充到相同的长度。在这个例子中,填充是手动进行的,而不是使用 TensorFlow 方法,这些方法我们之前已经见过。

本节中的所有代码将参考来自 GitHub 图书仓库 chapter5-nlg-with-transformer-gpt 文件夹中的 rnn-train.py 文件。该文件的第一部分包含导入和设置 GPU 的可选指令。如果您的设置没有使用 GPU,请忽略此部分。

GPU 对于深度学习工程师和研究人员来说是一个极好的投资。GPU 可以将训练时间提高几个数量级!因此,配置一台如 Nvidia GeForce RTX 2070 的 GPU 深度学习设备是值得的。

数据归一化和标记化的代码位于该文件的第 32 行到第 90 行之间。首先,需要设置标记化函数:

chars = sorted(set("abcdefghijklmnopqrstuvwxyz0123456789 -,;.!?:'''/\|_@#$%ˆ&*˜'+-=()[]{}' ABCDEFGHIJKLMNOPQRSTUVWXYZ"))
chars = list(chars)
EOS = '<EOS>'
UNK = "<UNK>"
PAD = "<PAD>"      # need to move mask to '0'index for Embedding layer
chars.append(UNK)
chars.append(EOS)  # end of sentence
chars.insert(0, PAD)  # now padding should get index of 0 

一旦令牌列表准备好,就需要定义方法将字符转换为令牌,反之亦然。创建映射相对简单:

# Creating a mapping from unique characters to indices
char2idx = {u:i for i, u in enumerate(chars)}
idx2char = np.array(chars)
def char_idx(c):
    # takes a character and returns an index
    # if character is not in list, returns the unknown token
    if c in chars:
        return char2idx[c]

    return char2idx[UNK] 

现在,数据需要从 TSV 文件中读取。对于标题,使用 75 个字符的最大长度。如果标题短于此长度,会进行填充。任何超过 75 个字符的标题都会被截断。<EOS> 标记会被附加到每个标题的末尾。我们来设置这个:

data = []     # load into this list of lists 
MAX_LEN = 75  # maximum length of a headline 
with open("news-headlines.tsv", "r") as file:
    lines = csv.reader(file, delimiter='\t')
    for line in lines:
        hdln = line[0]
        cnvrtd = [char_idx(c) for c in hdln[:-1]]  
        if len(cnvrtd) >= MAX_LEN:
            cnvrtd = cnvrtd[0:MAX_LEN-1]
            cnvrtd.append(char2idx[EOS])
        else:
            cnvrtd.append(char2idx[EOS])
            # add padding tokens
            remain = MAX_LEN - len(cnvrtd)
            if remain > 0:
                for i in range(remain):
                    cnvrtd.append(char2idx[PAD])
        data.append(cnvrtd)
print("**** Data file loaded ****") 

所有数据都已通过上述代码加载到列表中。你可能会想,训练的地面真实值是什么,因为我们只有一行文本。由于我们希望这个模型能够生成文本,目标可以简化为根据一组字符预测下一个字符。因此,采用一种技巧来构造真实值——我们只需将输入序列向右移动一个字符,并将其设置为期望输出。这个转换通过 numpy 很容易做到:

# now convert to numpy array
np_data = np.array(data)
# for training, we use one character shifted data
np_data_in = np_data[:, :-1]
np_data_out = np_data[:, 1:] 

通过这个巧妙的技巧,我们准备好了输入和期望的输出用于训练。最后一步是将其转换为 tf.Data.DataSet,以便于批处理和洗牌:

# Create TF dataset
x = tf.data.Dataset.from_tensor_slices((np_data_in, np_data_out)) 

现在一切准备就绪,可以开始训练了。

训练模型

模型训练的代码从 rnn-train.py 文件的第 90 行开始。该模型非常简单,包含一个嵌入层、一个 GRU 层和一个全连接层。词汇表的大小、RNN 单元的数量以及嵌入的大小已经设置好:

# Length of the vocabulary in chars
vocab_size = len(chars)
# The embedding dimension
embedding_dim = 256
# Number of RNN units
rnn_units = 1024
# batch size
BATCH_SIZE=256 

定义了批处理大小后,训练数据可以进行批处理,并准备好供模型使用:

# create tf.DataSet
x_train = x.shuffle(100000, reshuffle_each_iteration=True).batch(BATCH_SIZE, drop_remainder=True) 

与前几章中的代码类似,定义了一个方便的方法来构建模型,如下所示:

# define the model
def build_model(vocab_size, embedding_dim, rnn_units, batch_size):
  model = tf.keras.Sequential([
    tf.keras.layers.Embedding(vocab_size, embedding_dim,
                              mask_zero=True,
                              batch_input_shape=[batch_size, None]),
    tf.keras.layers.GRU(rnn_units,
                        return_sequences=True,
                        stateful=True,
                        recurrent_initializer='glorot_uniform'),
    tf.keras.layers.Dropout(0.1),
    tf.keras.layers.Dense(vocab_size)
  ])
  return model 

可以使用此方法实例化模型:

model = build_model(
                  vocab_size = vocab_size,
                  embedding_dim=embedding_dim,
                  rnn_units=rnn_units,
                  batch_size=BATCH_SIZE)
print("**** Model Instantiated ****")
print(model.summary()) 
**** Model Instantiated ****
Model: "sequential"
_________________________________________________________________
Layer (type)                 Output Shape              Param #
=================================================================
embedding (Embedding)        (256, None, 256)          24576
_________________________________________________________________
gru (GRU)                    (256, None, 1024)         3938304
_________________________________________________________________
dropout (Dropout)            (256, None, 1024)         0
_________________________________________________________________
dense (Dense)                (256, None, 96)           98400
=================================================================
Total params: 4,061,280
Trainable params: 4,061,280
Non-trainable params: 0
_________________________________________________________________ 

该模型有超过 400 万个可训练参数。训练该模型时使用了带稀疏分类损失函数的 Adam 优化器:

loss = tf.keras.losses.SparseCategoricalCrossentropy(from_logits=True)
model.compile(optimizer = 'adam', loss = loss) 

由于训练可能会耗费很长时间,我们需要在训练过程中设置检查点。如果训练过程中出现问题且训练停止,这些检查点可以用来从最后保存的检查点重新开始训练。通过当前的时间戳创建一个目录,用于保存这些检查点:

# Setup checkpoints 
# dynamically build folder names
dt = datetime.datetime.today().strftime("%Y-%b-%d-%H-%M-%S")
# Directory where the checkpoints will be saved
checkpoint_dir = './training_checkpoints/'+dt
# Name of the checkpoint files
checkpoint_prefix = os.path.join(checkpoint_dir, "ckpt_{epoch}")
checkpoint_callback=tf.keras.callbacks.ModelCheckpoint(
    filepath=checkpoint_prefix,
    save_weights_only=True) 

上面代码中的最后一行定义了一个在训练过程中保存检查点的自定义回调。这个回调会传递给 model.fit() 函数,以便在每个训练周期结束时调用。启动训练循环非常简单:

print("**** Start Training ****")
EPOCHS=25
start = time.time()
history = model.fit(x_train, epochs=EPOCHS, 
                    callbacks=[checkpoint_callback])
print("**** End Training ****")
print("Training time: ", time.time()- start) 

模型将训练 25 个周期。训练所需的时间也会在上面的代码中记录。最后一段代码使用训练历史来绘制损失曲线,并将其保存为 PNG 文件,保存在同一目录下:

# Plot accuracies
lossplot = "loss-" + dt + ".png"
plt.plot(history.history['loss'])
plt.title('model loss')
plt.xlabel('epoch')
plt.ylabel('loss')
plt.savefig(lossplot)
print("Saved loss to: ", lossplot) 

开始训练的最佳方式是启动 Python 进程,使其能够在后台运行,而无需终端或命令行。在 Unix 系统上,可以使用 nohup 命令来实现:

$ nohup python rnn-train.py > training.log & 

这个命令行启动进程,使得断开终端连接不会中断训练过程。在我的机器上,这次训练大约花费了 1 小时 43 分钟。让我们来看一下损失曲线:

一张*距离的男性面部照片  描述自动生成

图 5.1:损失曲线

正如我们所看到的,损失值会降低到一个点后突然上升。标准的预期是,随着模型训练轮数的增加,损失值应单调下降。在上面展示的情况下,损失值突然上升。在其他情况下,可能会观察到 NaN(不是一个数字)错误。NaN 错误是由 RNN 反向传播中的梯度爆炸问题引起的。梯度方向使得权重迅速变得非常大并导致溢出,最终产生 NaN 错误。由于这种情况非常普遍,关于 NLP 工程师和印度食物的笑话也随之而来,"NaN" 这个词也巧妙地指代了一种印度面包。

这些现象背后的主要原因是梯度下降超越了最小值,并在再次降低之前开始爬升坡度。这发生在梯度下降的步伐过大时。另一种防止 NaN 问题的方法是梯度裁剪,其中梯度被裁剪到一个绝对最大值,从而防止损失爆炸。在上面的 RNN 模型中,需要使用一种在训练过程中逐步减小学习率的方案。随着训练轮次的增加,减小学习率可以降低梯度下降超越最小值的可能性。这个逐步减小学习率的技巧被称为学习率退火学习率衰减。下一部分将介绍如何在训练模型时实现学习率衰减。

实现自定义学习率衰减回调

在 TensorFlow 中有两种实现学习率衰减的方法。第一种方法是使用 tf.keras.optimizers.schedulers 包中预构建的调度器之一,并将配置好的实例与优化器一起使用。一个预构建的调度器实例是 InverseTimeDecay,可以按照如下方式进行设置:

lr_schedule = tf.keras.optimizers.schedules.InverseTimeDecay(
  0.001,
  decay_steps=STEPS_PER_EPOCH*(EPOCHS/10),
  decay_rate=2,
  staircase=False) 

上述示例中的第一个参数 0.001 是初始学习率。每个周期的步数可以通过将训练样本数量除以批量大小来计算。衰减步数决定了学习率的减少方式。用来计算学习率的公式是:

设置完成后,该函数所需的仅是用于计算新学习率的步数。一旦设置好学习计划,它就可以传递给优化器:

optimizer = tf.keras.optimizers.Adam(lr_schedule) 

就这样!其余的训练循环代码保持不变。然而,这个学习率调度器从第一个周期开始就会减少学习率。较低的学习率会增加训练时间。理想情况下,我们会在前几个周期保持学习率不变,然后再减少它。

看看上面的图 5.1,学习率可能在大约第十个周期之前是有效的。BERT 还使用了学习率预热,然后才进行学习率衰减。学习率预热通常是指在几个周期内逐渐增加学习率。BERT 训练了 1000000 步,约等于 40 个周期。在前 10,000 步中,学习率是逐渐增加的,然后线性衰减。实现这样的学习率计划更好通过自定义回调来完成。

TensorFlow 中的自定义回调函数可以在训练和推理的不同阶段执行自定义逻辑。我们看到过一个预构建的回调函数,它在训练过程中保存检查点。自定义回调函数提供了钩子,使得可以在训练的不同阶段执行所需的逻辑。这个主要步骤是定义tf.keras.callbacks.Callback的子类。然后,可以实现以下一个或多个函数来挂钩 TensorFlow 暴露的事件:

  • on_[train,test,predict]_begin / on_[train,test,predict]_end:这个回调函数发生在训练开始时或训练结束时。这里有用于训练、测试和预测循环的方法。这些方法的名称可以使用方括号中显示的适当阶段名称来构造。方法命名约定是整个列表中其他方法的常见模式。

  • on_[train,test,predict]_batch_begin / on_[train,test,predict]_batch_end:这些回调函数在训练特定批次开始或结束时触发。

  • on_epoch_begin / on_epoch_end:这是一个特定于训练的函数,在每个周期开始或结束时调用。

我们将实现一个在每个周期开始时调整该周期学习率的回调函数。我们的实现会在可配置的初始周期数内保持学习率不变,然后以类似上述逆时间衰减函数的方式减少学习率。这个学习率图看起来像下面的图 5.2

一张手机的截图,自动生成的描述

图 5.2:自定义学习率衰减函数

首先,创建一个包含定义函数的子类。将其放置在rnn_train.py中最好的位置是在检查点回调附*,训练开始之前。该类定义如下所示:

class LearningRateScheduler(tf.keras.callbacks.Callback):
  """Learning rate scheduler which decays the learning rate"""
  def __init__(self, init_lr, decay, steps, start_epoch):
    super().__init__()
    self.init_lr = init_lr          # initial learning rate
    self.decay = decay              # how sharply to decay
    self.steps = steps              # total number of steps of decay
    self.start_epoch = start_epoch  # which epoch to start decaying
  def on_epoch_begin(self, epoch, logs=None):
    if not hasattr(self.model.optimizer, 'lr'):
      raise ValueError('Optimizer must have a "lr" attribute.')
    # Get the current learning rate
    lr = float(tf.keras.backend.get_value(self.model.optimizer.lr))
    if(epoch >= self.start_epoch):
        # Get the scheduled learning rate.
        scheduled_lr = self.init_lr / (1 + self.decay * (epoch / self.steps))
        # Set the new learning rate
        tf.keras.backend.set_value(self.model.optimizer.lr, 
                                     scheduled_lr)
    print('\nEpoch %05d: Learning rate is %6.4f.' % (epoch, scheduled_lr)) 

在训练循环中使用此回调函数需要实例化该回调函数。实例化回调时会设置以下参数:

  • 初始学习率设置为 0.001。

  • 衰减率设置为 4。请随意尝试不同的设置。

  • 步数设置为纪元数。模型被训练了 150 个纪元。

  • 学习率衰减应从第 10 个纪元后开始,因此开始的纪元设置为 10。

训练循环已更新,包含回调函数,如下所示:

print("**** Start Training ****")
EPOCHS=150
lr_decay = LearningRateScheduler(0.001, 4., EPOCHS, 10)
start = time.time()
history = model.fit(x_train, epochs=EPOCHS,
                    callbacks=[checkpoint_callback, lr_decay])
print("**** End Training ****")
print("Training time: ", time.time()- start)
print("Checkpoint directory: ", checkpoint_dir) 

以上变化已高亮显示。现在,模型已经准备好使用上述命令进行训练。训练 150 个纪元花费了超过 10 小时的 GPU 时间。损失曲面见图 5.3

A close up of a piece of paper  Description automatically generated

图 5.3:学习率衰减后的模型损失

在上图中,损失在前几个纪元中下降得非常快,然后在第 10 个纪元附*趋于*稳。此时,学习率衰减开始起作用,损失再次开始下降。这可以从日志文件中的一段代码中验证:

...
Epoch 8/150
2434/2434 [==================] - 249s 102ms/step - loss: 0.9055
Epoch 9/150
2434/2434 [==================] - 249s 102ms/step - loss: 0.9052
Epoch 10/150
2434/2434 [==================] - 249s 102ms/step - loss: `0.9064`
Epoch 00010: Learning rate is 0.00078947.
Epoch 11/150
2434/2434 [==================] - 249s 102ms/step - loss: `0.8949`
Epoch 00011: Learning rate is 0.00077320.
Epoch 12/150
2434/2434 [==================] - 249s 102ms/step - loss: 0.8888
...
Epoch 00149: Learning rate is 0.00020107.
Epoch 150/150
2434/2434 [==================] - 249s 102ms/step - loss: `0.7667`
**** End Training ****
Training time:  37361.16723680496
Checkpoint directory:  ./training_checkpoints/2021-Jan-01-09-55-03
Saved loss to:  loss-2021-Jan-01-09-55-03.png 

请注意上图中突出显示的损失。在第 10 个纪元左右,损失略微增加,原因是学习率衰减开始起作用,然后损失再次开始下降。图 5.3中可见的损失小波动与学习率高于需求的地方相关,学习率衰减将其降低,促使损失下降。学习率从 0.001 开始,最终降至 0.0002,即其五分之一。

训练这个模型花费了大量时间和先进的技巧,比如学习率衰减。但这个模型在生成文本方面表现如何呢?这是下一部分的重点。

使用贪心搜索生成文本

在训练过程中,每个纪元结束时都会进行检查点的保存。这些检查点用于加载已训练的模型以生成文本。这部分代码实现于一个 IPython 笔记本中。该部分代码位于本章 GitHub 文件夹中的charRNN-text-generation.ipynb文件中。文本生成依赖于训练过程中使用的相同归一化和标记化逻辑。笔记本中的设置标记化部分包含了这段代码的复本。

生成文本有两个主要步骤。第一步是从检查点恢复训练好的模型。第二步是从训练好的模型中逐个生成字符,直到满足特定的结束条件。

笔记本的加载模型部分包含定义模型的代码。由于检查点仅存储了层的权重,因此定义模型结构至关重要。与训练网络的主要区别在于批量大小。我们希望一次生成一句话,因此将批量大小设置为 1:

# Length of the vocabulary in chars
vocab_size = len(chars)
# The embedding dimension
embedding_dim = 256
# Number of RNN units
rnn_units = 1024
# Batch size
BATCH_SIZE=1 

定义模型结构的便利函数如下所示:

# this one is without padding masking or dropout layer
def build_gen_model(vocab_size, embedding_dim, rnn_units, batch_size):
  model = tf.keras.Sequential([
    tf.keras.layers.Embedding(vocab_size, embedding_dim,
                              batch_input_shape=[batch_size, None]),
    tf.keras.layers.GRU(rnn_units,
                        return_sequences=True,
                        stateful=True,
                        recurrent_initializer='glorot_uniform'),
    tf.keras.layers.Dense(vocab_size)
  ])
  return model
gen_model = build_gen_model(vocab_size, embedding_dim, rnn_units, 
                            BATCH_SIZE) 

注意,嵌入层不使用掩码,因为在文本生成中,我们不是传递整个序列,而只是需要完成的序列的一部分。现在模型已经定义好,可以从检查点中加载层的权重。请记住将检查点目录替换为包含训练检查点的本地目录:

checkpoint_dir = './training_checkpoints/**<YOUR-CHECKPOINT-DIR>'** 
gen_model.load_weights(tf.train.latest_checkpoint(checkpoint_dir))
gen_model.build(tf.TensorShape([1, None])) 

第二个主要步骤是逐个字符生成文本。生成文本需要一个种子或几个起始字母,这些字母由模型完成成一个句子。生成过程封装在下面的函数中:

def generate_text(model, start_string, temperature=0.7, num_generate=75):
  # Low temperatures results in more predictable text.
  # Higher temperatures results in more surprising text.
  # Experiment to find the best setting.
  # Converting our start string to numbers (vectorizing)
  input_eval = [char2idx[s] for s in start_string]
  input_eval = tf.expand_dims(input_eval, 0)
  # Empty string to store our results
  text_generated = []
  # Here batch size == 1
  for i in range(num_generate):
      predictions = model(input_eval)
      # remove the batch dimension
      predictions = tf.squeeze(predictions, 0)
      # using a categorical distribution to predict the 
      # word returned by the model
      predictions = predictions / temperature
      predicted_id = tf.random.categorical(predictions, 
                               num_samples=1)[-1,0].numpy()
      # We pass the predicted word as the next input to the model
      # along with the previous hidden state
      input_eval = tf.expand_dims([predicted_id], 0)

      text_generated.append(idx2char[predicted_id])
      # lets break is <EOS> token is generated
      # if idx2char[predicted_id] == EOS:
      # break #end of a sentence reached, let's stop
  return (start_string + ''.join(text_generated)) 

生成方法接收一个种子字符串作为生成的起始点。这个种子字符串被向量化。实际的生成过程在一个循环中进行,每次生成一个字符并附加到生成的序列中。在每一步中,选择具有最高概率的字符。选择具有最高概率的下一个字母被称为贪婪搜索。然而,有一个配置参数称为温度,可以用来调整生成文本的可预测性。

一旦预测出所有字符的概率,将概率除以温度会改变生成字符的分布。温度较小的值生成更接*原始文本的文本。温度较大的值生成更有创意的文本。在这里,选择了一个值为 0.7,更倾向于产生一些令人惊讶的内容。

生成文本所需的全部代码只需一行:

print(generate_text(gen_model, start_string=u"Google")) 
Google plans to release the Xbox One vs. Samsung Galaxy Gea<EOS><PAD>ote on Mother's Day 

每次执行命令可能会生成略有不同的结果。上面生成的行,虽然显然毫无意义,但结构相当良好。模型已经学习了大写规则和标题结构。通常情况下,我们不会生成超过<EOS>标记的文本,但在这里生成了所有 75 个字符,以便更好地理解模型输出。

注意,文本生成显示的输出是指示性的。对于相同的提示,您可能会看到不同的输出。这个过程内在地包含一些随机性,我们可以通过设置随机种子来尝试控制它。当重新训练模型时,它可能会停留在损失表面上的略有不同的点,即使损失数字看起来相似,模型权重也可能略有不同。请将整个章节中呈现的输出视为指示性的,而不是实际的。

这里还有一些种子字符串和模型输出的其他示例,这些示例在句子结束标记后被剪辑:

种子 生成的句子
标普 标普 500 首次突破 190标普:Russell Slive 再次找到任何商业制造商标普突破 2000 点首次突破
Beyonce Beyoncé和 Solange 一起为《美国偶像》比赛拍照Beyoncé的妹妹 Solange 主宰了《猩球崛起》的报告Beyoncé和 Jay Z 结婚

请注意,模型在前两句中使用了Beyonce作为种子词时的引号。下表展示了不同温度设置对类似种子词的影响:

种子 温度 生成的句子
标普 0.10.30.50.9 标普 500 首次突破 1900 点标普接* 57 亿美元收购 Beats Electronics 的交易标普 500 指数下跌 7.2%,预示着零售销售强劲标普,Ack 因素面临风险,你在这个市场看到了什么
Kim 0.10.30.50.9 Kim Kardashian 和 Kanye West 的婚礼照片发布Kim Kardashian 分享她对 Met Gala 首次亮相的最佳和最差看法Kim Kardashian 婚纱在 Fia 工作室制作中Kim Kardashian 的私人生活

通常,随着温度值的升高,文本的质量会下降。所有这些例子都是通过向生成函数传递不同的温度值生成的。

这种基于字符的模型的一个实际应用是完成文本消息或电子邮件应用中的单词。默认情况下,generate_text()方法会生成 75 个字符来完成标题。可以很容易地传入更短的长度,看看模型提出的下几个字母或单词是什么。

下表展示了一些实验,尝试完成文本片段的下 10 个字符。这些完成是通过以下方式生成的:

print(generate_text(gen_model, start_string=u"Lets meet tom", 
                    temperature=0.7, num_generate=10)) 
Lets meet tomorrow to t 
提示 完成
我需要一些来自银行的钱 我需要一些来自银行主席的钱
在盈利池中游泳 在盈利能力中游泳
你能给我一封 你能给我一封信吗
你是从哪儿的 你是从附*来的
会议是 会议恢复了
我们在 S 喝咖啡吧 我们在三星总部喝咖啡吧

鉴于使用的数据集仅来自新闻标题,它对某些类型的活动存在偏见。例如,第二句话本来可以用泳池来完成,而不是模型尝试用盈利能力来填充。如果使用更为通用的文本数据集,那么该模型在生成部分输入词语的完成时可能表现得很好。然而,这种文本生成方法有一个限制——使用了贪心搜索算法。

贪婪搜索过程是上述文本生成中的关键部分。它是生成文本的几种方式之一。我们通过一个例子来理解这个过程。在这个例子中,Peter Norvig 分析了二元组频率,并发布在norvig.com/mayzner.html上。在这项工作中分析了超过 7430 亿个英文单词。在一个没有大小写区分的模型中,理论上有 26 x 26 = 676 个二元组组合。然而,文章报告称,在大约 2.8 万亿个二元组实例中,从未见过以下二元组:JQ、QG、QK、QY、QZ、WQ 和 WZ。

笔记本中的贪婪搜索与二元组部分包含了下载和处理完整数据集的代码,并展示了贪婪搜索的过程。下载所有 n-gram 集合后,提取了二元组。构建了一组字典来帮助查找给定起始字母后的最高概率字母。然后,使用一些递归代码构建了一个树,选择下一个字母的前三个选择。在上述生成代码中,仅选择了最顶部的字母。然而,选择了前三个字母来展示贪婪搜索的工作原理及其缺点。

使用巧妙的anytree Python 包,可以可视化一个格式化良好的树。这棵树在以下图中展示:

一段白色背景上的文本特写,描述自动生成

图 5.4:从 WI 开始的贪婪搜索树

算法的任务是用总共五个字符完成WI。前面的树显示了给定路径的累计概率。显示了多条路径,这样可以看到贪婪搜索没有选择的分支。如果构建一个三字符单词,最高概率选择是WIN,概率为 0.243,其次是WIS,概率为 0.01128。如果考虑四个字母的单词,贪婪搜索将只考虑那些以WIN开头的单词,因为这是考虑前三个字母后,具有最高概率的路径。在这个路径中,WIND的概率为 0.000329。然而,快速扫描所有四个字母的单词后发现,概率最高的单词应该是WITH,概率为 0.000399。

本质上,这就是贪婪搜索算法在文本生成中的挑战。由于每个字符的优化,而不是累计概率,考虑联合概率的高概率选项被隐藏了。无论是按字符还是按词生成文本,贪婪搜索都面临相同的问题。

一种替代算法,称为束搜索(beam search),可以跟踪多个选项,并在生成过程中剔除低概率的选项。如图 5.4所示的树形结构也可以看作是跟踪概率束的示意图。为了展示这一技术的威力,使用一个更复杂的生成文本模型会更好。由 OpenAI 发布的GPT-2(Generative Pre-Training)模型设立了多个基准,包括在开放式文本生成方面的突破。这是本章下半部分的主题,其中首先解释了 GPT-2 模型。接下来的话题是对 GPT-2 模型进行微调,以完成电子邮件消息的生成。束搜索和其他改善生成文本质量的选项也会在接下来的内容中展示。

生成预训练(GPT-2)模型

OpenAI 于 2018 年 6 月发布了第一版 GPT 模型,随后在 2019 年 2 月发布了 GPT-2。由于担心恶意用途,GPT-2 的大规模模型没有与论文一起公开发布,因此引起了广泛关注。之后在 2019 年 11 月,OpenAI 发布了 GPT-2 的大型版本。GPT-3 模型是最新版本,于 2020 年 5 月发布。

图 5.5显示了这些模型中最大模型的参数数量:

图 5.5:不同 GPT 模型的参数

第一个模型采用了标准的 Transformer 解码器架构,具有 12 层,每层 12 个注意力头和 768 维的嵌入,总共有约 1.1 亿个参数,与 BERT 模型非常相似。最大的 GPT-2 模型拥有超过 15 亿个参数,而最*发布的 GPT-3 模型的最大变体拥有超过 1750 亿个参数!

语言模型训练成本

随着参数数量和数据集规模的增加,训练所需的时间也会增加。根据 Lambda Labs 的一篇文章,如果 GPT-3 模型仅在单个 Nvidia V100 GPU 上训练,训练时间将达到 342 年。使用微软 Azure 的标准定价,这将花费超过 300 万美元。GPT-2 模型的训练预计每小时花费 256 美元。假设训练时间与 BERT 类似(约四天),这将花费约 25,000 美元。如果在研究过程中需要训练多个模型,整体成本可能会增加十倍。

由于这种成本,个人甚至大多数公司无法从头开始训练这些模型。迁移学习和像 Hugging Face 这样的公司提供的预训练模型使得公众能够使用这些模型。

GPT 模型的基础架构使用了 Transformer 架构的解码器部分。解码器是一个从左到右的语言模型。相比之下,BERT 模型是一个双向模型。左到右的模型是自回归的,即它使用到目前为止生成的标记来生成下一个标记。由于它不能像双向模型一样看到未来的标记,这种语言模型非常适合文本生成。

图 5.6 显示了完整的 Transformer 架构,左侧是编码器块,右侧是解码器块:

图 5.6:完整的 Transformer 架构,包含编码器块和解码器块

图 5.6 的左侧应该很熟悉——它基本上是上一章Transformer 模型部分中的图 4.6。所示的编码器块与 BERT 模型相同。解码器块与编码器块非常相似,有几个显著的不同之处。

在编码器块中,只有一个输入源——输入序列,所有输入标记都可以用于多头注意力操作。这使得编码器能够从左右两侧理解标记的上下文。

在解码器块中,每个块有两个输入。编码器块生成的输出对所有解码器块可用,并通过多头注意力和层归一化传递到解码器块的中间。

什么是层归一化?

大型深度神经网络使用随机梯度下降SGD)优化器或类似的变体如 Adam 进行训练。在大数据集上训练大型模型可能需要相当长的时间才能使模型收敛。诸如权重归一化、批量归一化和层归一化等技术旨在通过帮助模型更快地收敛来减少训练时间,同时还起到正则化的作用。层归一化的基本思想是根据输入的均值和标准差对给定隐藏层的输入进行缩放。首先,计算均值和标准差:

H 表示层 l 中的隐藏单元数量。层的输入通过上述计算的值进行归一化:

其中 g 是一个增益参数。请注意,均值和标准差的公式与小批量的大小或数据集的大小无关。因此,这种类型的归一化可以用于 RNN 和其他序列建模问题。

然而,到目前为止由解码器生成的标记会通过掩码的多头自注意力回馈,并与来自编码器块的输出相加。这里的掩码指的是生成的标记右侧的标记被掩盖,解码器看不见它们。与编码器类似,这里有多个这样的块堆叠在一起。然而,GPT 架构仅是 Transformer 的一半,这需要对架构进行一些修改。

GPT 的修改架构如 图 5.7 所示。由于没有编码器块来传递输入序列的表示,因此不再需要多头层。模型生成的输出会递归地反馈,用以生成下一个标记。

最小的 GPT-2 模型有 12 层,每个标记有 768 个维度。最大的 GPT-2 模型有 48 层,每个标记有 1,600 个维度。为了预训练这种规模的模型,GPT-2 的作者需要创建一个新的数据集。网页是很好的文本来源,但文本存在质量问题。为了解决这个问题,他们从 Reddit 上抓取了所有至少获得三点 karma 的外部链接。作者的假设是 karma 点数可以作为网页质量的一个指标。这一假设使得抓取大量文本数据成为可能。最终的数据集大约包含 4500 万个链接。

为了从网页的 HTML 中提取文本,使用了两个 Python 库:Dragnet 和 Newspaper。经过一些质量检查和去重处理,最终的数据集约有 800 万份文档,总共 40 GB 的文本数据。令人兴奋的是,作者还去除了所有维基百科文档,因为他们认为许多测试数据集都使用了维基百科,加入这些页面会导致测试和训练数据集的重叠。预训练的目标是一个标准的语言模型训练目标:根据一组前置词预测下一个词:

标志的特写  描述自动生成

图 5.7:GPT 架构(来源:Radford 等人的《通过生成预训练改进语言理解》)

在预训练过程中,GPT-2 模型使用最大序列长度 1,024 个标记进行训练。采用 字节对编码BPE)算法进行分词,词汇表大小约为 50,000 个标记。GPT-2 使用字节序列而不是 Unicode 码点进行字节对合并。如果 GPT-2 仅使用字节进行编码,则词汇表的大小将只有 256 个标记。另一方面,使用 Unicode 码点将导致词汇表超过 130,000 个标记。通过巧妙地使用 BPE 中的字节,GPT-2 能够将词汇表大小控制在一个可管理的 50,257 个标记。

GPT-2 中的分词器还有一个特点,它将所有文本转换为小写字母,并在使用 BPE 之前使用 spaCy 和 ftfy 分词器。ftfy 库对于修复 Unicode 问题非常有用。如果这两个库不可用,则会使用基本的 BERT 分词器。

尽管从左到右的模型可能看起来有限制,但有几种方法可以编码输入以解决不同的问题。这些方法显示在 图 5.8 中:

手机截图  描述自动生成

图 5.8:GPT-2 在不同问题中的输入转换(来源:Radford 等人的《通过生成预训练改进语言理解》)

上图展示了如何使用预训练的 GPT-2 模型来处理文本生成以外的多种任务。在每个实例中,输入序列的前后分别添加了开始和结束标记。在所有情况下,最后都会添加一个线性层,并且在模型微调时进行训练。所宣称的主要优势是,许多不同类型的任务可以使用相同的架构来完成。图 5.8中的最上层架构展示了它如何用于分类。例如,GPT-2 可以使用这种方法进行 IMDb 情感分析。

第二个示例是文本蕴含。文本蕴含是一个 NLP 任务,需要确定两个文本片段之间的关系。第一个文本片段称为前提,第二个片段称为假设。前提和假设之间可以存在不同的关系。前提可以验证或与假设相矛盾,或者它们可能没有任何关系。

假设前提是每天锻炼是健康生活方式和长寿的重要组成部分。如果假设是锻炼增加寿命,那么前提蕴含验证了假设。另一方面,如果假设是跑步没有任何好处,那么前提假设相矛盾。最后,如果假设是举重可以锻炼出六块腹肌,那么前提既不蕴含也不与假设相矛盾。为了使用 GPT-2 进行蕴含推理,前提和假设被用分隔符通常是$连接起来。

对于文本相似性,构造两个输入序列,一个将第一个文本序列放在前面,另一个将第二个文本序列放在前面。GPT 模型的输出结果相加并传入线性层。类似的方法也可以用于多项选择题。然而,本章的重点是文本生成。

使用 GPT-2 生成文本

Hugging Face 的 transformers 库简化了使用 GPT-2 生成文本的过程。类似于前一章所示的预训练 BERT 模型,Hugging Face 提供了预训练的 GPT 和 GPT-2 模型。这些预训练模型将在本章的其余部分中使用。此代码和本章其余部分的代码可以在名为text-generation-with-GPT-2.ipynb的 IPython 笔记本中找到。运行设置后,转到使用 GPT-2 生成文本部分。还提供了一个展示如何使用 GPT 生成文本的部分作为参考。生成文本的第一步是下载预训练模型及其相应的 tokenizer:

from transformers import TFGPT2LMHeadModel, GPT2Tokenizer
gpt2tokenizer = GPT2Tokenizer.from_pretrained("gpt2")
# add the EOS token as PAD token to avoid warnings
gpt2 = TFGPT2LMHeadModel.from_pretrained("gpt2", 
                          pad_token_id=gpt2tokenizer.eos_token_id) 

这可能需要几分钟时间,因为模型需要下载。如果在您的环境中没有找到 spaCy 和ftfy,您可能会看到警告。这两个库对于文本生成并不是强制性的。以下代码可以使用贪婪搜索算法来生成文本:

# encode context the generation is conditioned on
input_ids = gpt2tokenizer.encode('Robotics is the domain of ', return_tensors='tf')
# generate text until the output length 
# (which includes the context length) reaches 50
greedy_output = gpt2.generate(input_ids, max_length=50)
print("Output:\n" + 50 * '-')
print(gpt2tokenizer.decode(greedy_output[0], skip_special_tokens=True)) 
Output:
-----------------------------------------------------------
Robotics is the domain of the United States Government.
The United States Government is the primary source of information on the use of drones in the United States.
The United States Government is the primary source of information on the use of drones 

提供了一个提示词供模型完成。模型开始时表现不错,但很快开始重复相同的输出。

请注意,生成文本的输出只是一个示例。对于相同的提示,您可能会看到不同的输出。这有几个不同的原因。此过程本身具有一定的随机性,我们可以通过设置随机种子来尝试控制它。Hugging Face 团队可能会定期重新训练模型,并且模型可能会随着新版本的发布而发生变化。

前一节中提到了贪婪搜索的问题。束搜索可以作为一种替代方案。在生成每个标记的步骤中,会保留一组具有最高概率的标记作为束的一部分,而不仅仅是保留最高概率的标记。在生成结束时,会返回具有最高总体概率的序列。前一节中的图 5.4(使用贪婪搜索)可以视为束搜索算法的输出,束的大小为 3\。

使用束搜索生成文本是很简单的:

# BEAM SEARCH
# activate beam search and early_stopping
beam_output = gpt2.generate(
    input_ids, 
    max_length=50, 
    num_beams=5, 
    early_stopping=True
)
print("Output:\n" + 50 * '-')
print(gpt2tokenizer.decode(beam_output[0], skip_special_tokens=True)) 
Output:
--------------------------------------------------
Robotics is the domain of science and technology. It is the domain of science and technology. It is the domain of science and technology. It is the domain of science and technology. It is the domain of science and technology. It is the domain 

从质量上讲,第一句比贪婪搜索生成的句子更有意义。early_stopping参数会在所有束到达 EOS 标记时指示停止生成。然而,仍然存在很多重复的情况。控制重复的一个参数是通过设置限制,防止 n-grams 重复:

# set no_repeat_ngram_size to 2
beam_output = gpt2.generate(
    input_ids, 
    max_length=50, 
    num_beams=5, 
    no_repeat_ngram_size=3, 
    early_stopping=True
)
print("Output:\n" + 50 * '-')
print(gpt2tokenizer.decode(beam_output[0], skip_special_tokens=True)) 
Output:
--------------------------------------------------
Robotics is the domain of science and technology.
In this article, we will look at some of the most important aspects of robotics and how they can be used to improve the lives of people around the world. We will also take a look 

这对生成文本的质量产生了相当大的影响。no_repeat_ngram_size参数可以防止模型生成任何 3-gram 或三元组的重复。虽然这提高了文本的质量,但使用 n-gram 约束可能会对生成文本的质量产生显著影响。如果生成的文本是关于白宫的,那么这三个词只能在整个生成文本中使用一次。在这种情况下,使用 n-gram 约束会适得其反。

是否使用束搜索

当生成的序列长度受到限制时,束搜索效果很好。随着序列长度的增加,需要维护和计算的束数量显著增加。因此,束搜索适用于像总结和翻译这样的任务,但在开放式文本生成中表现较差。此外,束搜索通过尝试最大化累积概率,生成了更多可预测的文本。这使得文本感觉不太自然。以下代码可以用来感受生成的不同束。确保束的数量大于或等于返回的序列数:

# Returning multiple beams
beam_outputs = gpt2.generate(
    input_ids, 
    max_length=50, 
    num_beams=7, 
    no_repeat_ngram_size=3, 
    num_return_sequences=3,  
    early_stopping=True,
    temperature=0.7
)
print("Output:\n" + 50 * '-')
for i, beam_output in enumerate(beam_outputs):
  print("\n{}: {}".format(i, 
                   gpt2tokenizer.decode(beam_output,
                          skip_special_tokens=True))) 
Output:
--------------------------------------------------
0: Robotics is the domain of the U.S. Department of Homeland Security. The agency is responsible for the security of the United States and its allies, including the United Kingdom, Canada, Australia, New Zealand, and the European Union.
1: Robotics is the domain of the U.S. Department of Homeland Security. The agency is responsible for the security of the United States and its allies, including the United Kingdom, France, Germany, Italy, Japan, and the European Union.
2: Robotics is the domain of the U.S. Department of Homeland Security. The agency is responsible for the security of the United States and its allies, including the United Kingdom, Canada, Australia, New Zealand, the European Union, and the United
The text generated is very similar but differs near the end. Also, note that temperature is available to control the creativity of the generated text. 

还有一种方法可以提高生成文本的连贯性和创造性,这种方法叫做 Top-K 采样。这是 GPT-2 中首选的方法,在 GPT-2 生成故事的成功中起着至关重要的作用。在解释这个方法如何工作之前,我们先试试看,并看看生成的输出:

# Top-K sampling
tf.random.set_seed(42)  # for reproducible results
beam_output = gpt2.generate(
    input_ids, 
    max_length=50, 
    do_sample=True, 
    top_k=25,
    temperature=2
)
print("Output:\n" + 50 * '-')
print(gpt2tokenizer.decode(beam_output[0], skip_special_tokens=True)) 
Output:
--------------------------------------------------
Robotics is the domain of people with multiple careers working with robotics systems. The purpose of Robotics & Machine Learning in Science and engineering research is not necessarily different for any given research type because the results would be much more diverse.
Our team uses 

上述示例是通过选择一个高温值生成的。设置了一个随机种子,以确保结果可重复。Top-K 采样方法在 2018 年由 Fan Lewis 和 Dauphin 在论文《层级神经故事生成》中发布。这个算法相对简单——每一步,它从概率最大的K个 tokens 中挑选一个。如果K设置为 1,那么该算法就与贪心搜索相同。

在上面的代码示例中,模型在生成文本时会查看 50,000+个 tokens 中的前 25 个最顶端的 tokens。然后,从这些 tokens 中随机选择一个,并继续生成文本。选择更大的值会产生更惊讶或更富有创意的文本。选择较低的K值则会生成更可预测的文本。如果到目前为止你觉得结果有些让人失望,那是因为所选择的提示确实很难。请考虑这是使用 Top-K 为 50 时,为提示在黑夜的深处,突然出现了一生成的输出:

在黑夜的深处,突然出现了一束光。

叹了口气,萧辰慢慢站起身,看着站在他面前的天承。他迈步走*,仔细观察天承的左腕,眉头紧皱。

林峰吓了一跳,迅速抽出一把长剑!

林峰不明白龙飞在黑晶宫里挥舞的是什么样的剑!

黑晶宫与他原本的黑石城完全不同。龙飞带着一把剑做为纪念,这把剑是天承将其放置在他父亲的手臂上的。

他又从父亲的手臂上拔出了那把剑!

这把黑色的剑刃是黑晶宫中最有价值的武器之一。这把剑锋利得像所有武器中最锋利的一把,它被黑石城的黑冰放置在龙飞父亲的手臂上,供他使用。

上述较长的文本是由最小的 GPT-2 模型生成的,该模型大约有 1.24 亿个参数。目前有多个不同的设置和模型大小供你使用。记住,强大的能力伴随巨大的责任。

在上一章和这一章之间,我们已经从概念上涵盖了 Transformer 架构中的编码器和解码器部分。现在,我们准备在下一章将这两部分结合起来。让我们快速回顾一下本章的内容。

总结

生成文本是一个复杂的任务。它有实际的用途,可以使打字文本消息或撰写电子邮件变得更加容易。另一方面,还有创造性的用途,如生成故事。在本章中,我们介绍了基于字符的 RNN 模型,逐个字符生成标题,并注意到它在结构、大写和其他方面表现出色。尽管模型是在特定数据集上训练的,但它在根据上下文完成短句和部分打字词方面表现出了潜力。接下来的部分介绍了基于 Transformer 解码器架构的最先进 GPT-2 模型。前一章已经介绍了 Transformer 编码器架构,BERT 使用了该架构。

生成文本有许多可以调节的参数,如重新采样分布的温度、贪婪搜索、波束搜索和 Top-K 采样,以*衡生成文本的创造性和可预测性。我们看到了这些设置对文本生成的影响,并使用了 Hugging Face 提供的预训练 GPT-2 模型来生成文本。

现在我们已经介绍了 Transformer 架构的编码器和解码器部分,下一章将使用完整的 Transformer 构建一个文本摘要模型。文本摘要技术正处于自然语言处理的前沿。我们将建立一个模型,能够阅读新闻文章并用几句话总结出来。继续前进!

第六章:使用 Seq2seq 注意力机制和 Transformer 网络进行文本摘要

总结一篇文本挑战了深度学习模型对语言的理解。摘要可以看作是一个独特的人类能力,需要理解文本的要点并加以表述。在前面的章节中,我们构建了有助于摘要的组件。首先,我们使用 BERT 对文本进行编码并执行情感分析。然后,我们使用 GPT-2 的解码器架构来生成文本。将编码器和解码器结合起来,形成了一个摘要模型。在本章中,我们将实现一个带有 Bahdanau 注意力机制的 seq2seq 编码器-解码器。具体来说,我们将涵盖以下主题:

  • 提取式和抽象式文本摘要概述

  • 使用带有注意力机制的 seq2seq 模型进行文本摘要

  • 通过束搜索改进摘要

  • 通过长度归一化解决束搜索问题

  • 使用 ROUGE 指标衡量摘要性能

  • 最新摘要技术的回顾

这一过程的第一步是理解文本摘要背后的主要思想。在构建模型之前,理解任务本身至关重要。

文本摘要概述

摘要的核心思想是将长篇文本或文章浓缩为简短的表示形式。简短的表示应包含长文本中的关键信息。单个文档可以被总结,这个文档可以很长,也可以只有几句话。一个短文档摘要的例子是从文章的前几句话生成标题。这被称为句子压缩。当总结多个文档时,它们通常是相关的。它们可以是公司财务报告,或者关于某个事件的新闻报道。生成的摘要可以长也可以短。当生成标题时,通常希望摘要较短;而较长的摘要则像摘要部分,可能包含多句话。

总结文本时有两种主要的方法:

  • 提取式摘要:从文章中选取短语或句子,并将其组合成摘要。这种方法的思维模型类似于在长篇文本上使用荧光笔,摘要即是这些重点内容的组合。提取式摘要是一种更直接的方法,因为可以直接复制源文本中的句子,从而减少语法问题。摘要的质量也可以通过诸如 ROUGE 等指标来衡量。该指标将在本章后面详细介绍。提取式摘要在深度学习和神经网络出现之前是主要的方法。

  • 抽象式摘要:在摘要一篇文章时,个人可能会使用该语言中所有可用的词汇。他们不局限于仅使用文章中的词语。其心理模型是,人们正在撰写一篇新的文本。模型必须对不同词语的意义有所理解,才能在摘要中使用这些词汇。抽象式摘要相当难以实现和评估。Seq2Seq 架构的出现大大提升了抽象式摘要模型的质量。

本章重点讨论抽象式摘要。以下是我们模型生成的摘要示例:

原文 生成的摘要
美国航空集团公司周日表示,它计划通过出售股票和可转换优先票据筹集##亿美元,以改善航空公司在应对因冠状病毒引起的旅行限制时的流动性。 美国航空将通过可转换债券发行筹集##亿美元
新建独栋住宅的销售在 5 月按季节调整后的年化速度为##,与 4 月经过下调修正后的##相比,增长了#.#%。 新房销售在 5 月上涨
JC Penney 将永久关闭更多##家门店。这家上个月申请破产的百货商店连锁,正朝着关闭##家门店的目标迈进。 JC Penney 将关闭更多门店

原文在预处理时已全部转换为小写,并且将数字替换为占位符标记,以防止模型在摘要中捏造数字。生成的摘要中有些词被高亮显示,这些词在原文中并未出现。模型能够在摘要中提出这些词。因此,模型是一个抽象式摘要模型。那么,如何构建这样一个模型呢?

一种看待摘要生成问题的方法是,模型将输入的标记序列转换为较小的输出标记集合。模型根据提供的监督样本学习输出长度。另一个著名的问题是将输入序列映射到输出序列——即神经机器翻译(NMT)问题。在 NMT 中,输入序列可能是源语言中的一句话,输出则可能是目标语言中的一系列标记。翻译过程如下:

  1. 将输入文本转换为标记

  2. 为这些标记学习嵌入

  3. 通过编码器传递标记嵌入,计算隐藏状态和输出

  4. 使用带有注意力机制的隐藏状态生成输入的上下文向量

  5. 将编码器输出、隐藏状态和上下文向量传递给网络的解码器部分

  6. 使用自回归模型从左到右生成输出

Google AI 在 2017 年 7 月发布了一个关于使用 seq2seq 注意力模型进行神经机器翻译(NMT)的教程。该模型使用带有 GRU 单元的从左到右的编码器。解码器也使用 GRU 单元。在文本摘要中,被总结的文本是前提条件。这对于机器翻译来说可能有效,也可能无效。在某些情况下,翻译是实时进行的。在这种情况下,从左到右的编码器是有用的。然而,如果待翻译或总结的完整文本从一开始就可以获取,那么双向编码器可以从给定词元的两侧编码上下文。编码器中的双向 RNN(BiRNN)可以显著提升整体模型的性能。NMT 教程中的代码为 seq2seq 注意力模型和之前提到的注意力教程提供了灵感。在我们进行模型构建之前,让我们先了解一下为此目的使用的数据集。

数据加载与预处理

有多个与摘要相关的数据集可用于训练。这些数据集可以通过 TensorFlow Datasets 或tfds包获得,我们在前面的章节中也使用过这些包。可用的数据集在长度和风格上有所不同。CNN/DailyMail 数据集是最常用的数据集之一。该数据集于 2015 年发布,包含约 100 万篇新闻文章。收集的文章来自 CNN(自 2007 年起)和 Daily Mail(自 2010 年起),直到 2015 年为止。摘要通常是多句话的。Newsroom 数据集可以通过summari.es获取,包含来自 38 家出版物的超过 130 万篇新闻文章。然而,这个数据集需要注册后才能下载,因此本书中未使用此数据集。wikiHow 数据集包含完整的维基百科文章页面以及这些文章的摘要句子。LCSTS 数据集包含从新浪微博收集的中文数据,包含段落及其单句摘要。

另一个流行的数据集是 Gigaword 数据集。它提供了新闻故事的前一到两句话,并以新闻标题作为摘要。这个数据集相当庞大,包含了* 400 万行数据。该数据集在 2011 年由 Napoles 等人在一篇名为Annotated Gigaword的论文中发布。使用tfds导入这个数据集非常容易。考虑到数据集的庞大规模以及模型的长时间训练,训练代码存储在 Python 文件中,而推理代码则在 IPython 笔记本中。上一章也使用了这种模式。训练代码在s2s-training.py文件中。该文件的顶部包含导入语句以及一个名为setupGPU()的方法,用于初始化 GPU。文件中还有一个主函数,提供控制流,并包含多个执行特定操作的函数。

数据集需要首先加载。加载数据的代码在load_data()函数中:

def load_data():
    print("Loading the dataset")
    (ds_train, ds_val, ds_test), ds_info = tfds.load(
        'gigaword',
        split=['train', 'validation', 'test'],
        shuffle_files=True,
        as_supervised=True,
        with_info=True,
    )
    return ds_train, ds_val, ds_test 

主函数中对应的部分如下所示:

if __name__ == "__main__":
    setupGPU()  # OPTIONAL – only if using GPU
    ds_train, _, _ = load_data() 

仅加载训练数据集。验证数据集包含约 190,000 个样本,而测试集包含超过 1,900 个样本。相比之下,训练集包含超过 380 万个样本。根据网络连接情况,下载数据集可能需要一些时间:

Downloading and preparing dataset gigaword/1.2.0 (download: 551.61 MiB, generated: Unknown size, total: 551.61 MiB) to /xxx/tensorflow_datasets/gigaword/1.2.0...
/xxx/anaconda3/envs/tf21g/lib/python3.7/site-packages/urllib3/connectionpool.py:986: InsecureRequestWarning: Unverified HTTPS request is being made to host 'drive.google.com'. Adding certificate verification is strongly advised. See: https://urllib3.readthedocs.io/en/latest/advanced-usage.html#ssl-warnings
  InsecureRequestWarning, 
  InsecureRequestWarning,
Shuffling and writing examples to /xxx/tensorflow_datasets/gigaword/1.2.0.incomplete1FP5M4/gigaword-train.tfrecord
100%
<snip/>
100%
1950/1951 [00:00<00:00, 45393.40 examples/s]
Dataset gigaword downloaded and prepared to /xxx/tensorflow_datasets/gigaword/1.2.0\. Subsequent calls will reuse this data. 

关于不安全请求的警告可以安全忽略。数据现在已经准备好进行分词和向量化处理。

数据分词和向量化

Gigaword 数据集已经使用 StanfordNLP 分词器进行了清洗、规范化和分词。所有数据都已转换为小写,并使用 StanfordNLP 分词器进行了规范化,正如前面的示例所示。本步骤的主要任务是创建词汇表。基于单词的分词器是摘要生成中最常见的选择。然而,本章将使用子词分词器。子词分词器的优点是可以在最小化未知词数量的同时限制词汇表的大小。第三章使用 BiLSTMs、CRFs 和维特比解码进行命名实体识别(NER),介绍了不同类型的分词器。因此,像 BERT 和 GPT-2 这样的模型使用某种变体的子词分词器。tfds 包提供了一种方法,可以从文本语料库初始化并创建一个子词分词器。由于生成词汇表需要遍历所有训练数据,因此这个过程可能比较慢。初始化后,分词器可以保存到磁盘以供将来使用。这个过程的代码在 get_tokenizer() 函数中定义:

def get_tokenizer(data, file="gigaword32k.enc"):
    if os.path.exists(file+.subwords):
        # data has already been tokenized - just load and return
        tokenizer = \
tfds.features.text.SubwordTextEncoder.load_from_file(file)
    else:
        # This takes a while
        tokenizer = \
tfds.features.text.SubwordTextEncoder.build_from_corpus(
        ((art.numpy() + b" " + smm.numpy()) for art, smm in data),
        target_vocab_size=2**15
        )  # End tokenizer construction
        tokenizer.save_to_file(file)  # save for future iterations

   print("Tokenizer ready. Total vocabulary size: ", tokenizer.vocab_size)
   return tokenizer 

该方法检查是否已保存子词分词器并加载它。如果磁盘上没有分词器,则通过将文章和摘要合并输入来创建一个新的分词器。请注意,在我的机器上创建新分词器花费了超过 20 分钟。

因此,最好只执行一次这个过程,并将结果保存以供将来使用。本章的 GitHub 文件夹包含了已保存的分词器版本,以节省一些时间。

在创建词汇表后,会向其中添加两个额外的标记,表示序列的开始和结束。这些标记帮助模型开始和结束输入输出。结束标记为生成摘要的解码器提供了一种信号,表示摘要的结束。此时,主要方法如下所示:

if __name__ == "__main__":
    setupGPU()  # OPTIONAL - only if using GPU
    ds_train, _, _ = load_data()
    tokenizer = get_tokenizer(ds_train)
    # Test tokenizer
    txt = "Coronavirus spread surprised everyone"
    print(txt, " => ", tokenizer.encode(txt.lower()))
    for ts in tokenizer.encode(txt.lower()):
        print ('{} ----> {}'.format(ts, tokenizer.decode([ts])))
    # add start and end of sentence tokens
    start = tokenizer.vocab_size + 1 
    end = tokenizer.vocab_size
    vocab_size = end + 2 

文章及其摘要可以使用分词器进行分词。文章的长度各异,需要在最大长度处进行截断。由于 Gigaword 数据集仅包含文章中的少数几句话,因此选择了最大令牌长度为 128。请注意,128 个令牌并不等同于 128 个单词,因为使用的是子词分词器。使用子词分词器可以最小化生成摘要时出现未知令牌的情况。

一旦分词器准备好,文章和摘要文本都需要进行分词。由于摘要将一次性传递给解码器一个标记,提供的摘要文本会通过添加一个 start 标记向右偏移,正如之前所示。一个 end 标记将被附加到摘要末尾,以便让解码器学会如何标识摘要生成的结束。文件 seq2seq.py 中的 encode() 方法定义了向量化步骤:

def encode(article, summary, start=start, end=end, 
           tokenizer=tokenizer, art_max_len=128, 
           smry_max_len=50):
    # vectorize article
    tokens = tokenizer.encode(article.numpy())
    if len(tokens) > art_max_len:
        tokens = tokens[:art_max_len]
    art_enc = sequence.pad_sequences([tokens], padding='post',
                                 maxlen=art_max_len).squeeze()
    # vectorize summary
    tokens = [start] + tokenizer.encode(summary.numpy())
    if len(tokens) > smry_max_len:
        tokens = tokens[:smry_max_len]
    else:
        tokens = tokens + [end]

    smry_enc = sequence.pad_sequences([tokens], padding='post',
                                 maxlen=smry_max_len).squeeze()
    return art_enc, smry_enc 

由于这是一个处理张量文本内容的 Python 函数,因此需要定义另一个函数。这个函数可以传递给数据集,以便应用到数据的所有行。这个函数也在与 encode 函数相同的文件中定义:

def tf_encode(article, summary):
    art_enc, smry_enc = tf.py_function(encode, [article, summary],
                                     [tf.int64, tf.int64])
    art_enc.set_shape([None])
    smry_enc.set_shape([None])
    return art_enc, smry_enc 

回到 s2s-training.py 文件中的主函数,数据集可以借助之前的函数进行向量化,如下所示:

BUFFER_SIZE = 1500000  # dataset is 3.8M samples, using less
BATCH_SIZE = 64  # try bigger batch for faster training
train = ds_train.take(BUFFER_SIZE)  # 1.5M samples
print("Dataset sample taken")
train_dataset = train.map(s2s.tf_encode)
# train_dataset = train_dataset.shuffle(BUFFER_SIZE) – optional 
train_dataset = train_dataset.batch(BATCH_SIZE, drop_remainder=True)
print("Dataset batching done") 

请注意,建议对数据集进行打乱。通过打乱数据集,模型更容易收敛,并且不容易对批次过拟合。然而,这会增加训练时间。这里将其注释掉,因为这是一个可选步骤。在为生产用例训练模型时,建议在批次中打乱记录。准备数据的最后一步是将其分批,如此处的最后一步所示。现在,我们可以开始构建模型并进行训练。

Seq2seq 模型与注意力机制

摘要模型有一个包含双向 RNN 的编码器部分和一个单向解码器部分。存在一个注意力层,它帮助解码器在生成输出标记时集中关注输入的特定部分。整体架构如下图所示:

图 6.1:Seq2seq 和注意力模型

这些层将在以下小节中详细介绍。模型的所有代码都在文件 seq2seq.py 中。这些层使用在 s2s-training.py 文件主函数中指定的通用超参数:

embedding_dim = 128
units = 256  # from pointer generator paper 

这一部分的代码和架构灵感来自 2017 年 4 月由 Abigail See、Peter Liu 和 Chris Manning 发表的论文 Get To The Point: Summarization with Pointer-Generator Networks。基础架构易于理解,并且对于可以在普通桌面 GPU 上训练的模型提供了令人印象深刻的性能。

编码器模型

编码器层的详细架构如下面的图所示。经过分词和向量化的输入会通过嵌入层。分词器生成的令牌的嵌入是从头开始学习的。也可以使用一组预训练的嵌入,例如 GloVe,并使用相应的分词器。虽然使用预训练的嵌入集可以提高模型的准确性,但基于词汇的词汇表会有很多未知令牌,正如我们在 IMDb 示例和之前的 GloVe 向量中看到的那样。这些未知令牌会影响模型生成之前未见过的词语的摘要的能力。如果将摘要模型用于日常新闻,可能会有多个未知词汇,例如人名、地名或新产品名:

A screenshot of a cell phone  Description automatically generated

图 6.2:编码器架构

嵌入层的维度为 128,正如在超参数中配置的那样。这些超参数的选择是为了与论文中的配置相似。接下来,我们创建一个嵌入单例,可以被编码器和解码器共享。该类的代码在seq2seq.py文件中:

class Embedding(object):
    embedding = None  # singleton
    @classmethod
    def get_embedding(self, vocab_size, embedding_dim):
        if self.embedding is None:
            self.embedding = tf.keras.layers.Embedding(vocab_size,
                                                      embedding_dim,
                                                      mask_zero=True)
        return self.embedding 

输入序列将填充到固定长度 128。因此,将一个掩码参数传递给嵌入层,以使嵌入层忽略掩码令牌。接下来,让我们在构造函数中定义一个Encoder类并实例化嵌入层:

# Encoder
class Encoder(tf.keras.Model):
    def __init__(self, vocab_size, embedding_dim, enc_units, batch_size):
        super(Encoder, self).__init__()
        self.batch_size = batch_size
        self.enc_units = enc_units
        # Shared embedding layer
        self.embedding = Embedding.get_embedding(vocab_size, 
                                                 embedding_dim) 

构造函数接受多个参数:

  • 词汇表大小:在当前情况下,词汇表大小为 32,899 个令牌。

  • 嵌入维度:这是 128 维。可以尝试使用更大或更小的嵌入维度。较小的维度可以减少模型的大小和训练模型所需的内存。

  • 编码器单元:双向层中正向和反向单元的数量。将使用 256 个单元,总共 512 个单元。

  • 批处理大小:输入批次的大小。64 条记录将组成一个批次。较大的批次可以加速训练,但会需要更多的 GPU 内存。因此,这个数字可以根据训练硬件的容量进行调整。

嵌入层的输出被传递给一个双向 RNN 层。每个方向有 256 个 GRU 单元。Keras 中的双向层提供了如何组合正向和反向层输出的选项。在这种情况下,我们将正向和反向 GRU 单元的输出进行连接。因此,输出将是 512 维的。此外,注意机制需要隐藏状态,所以需要传递一个参数来获取输出状态。双向 GRU 层的配置如下:

 self.bigru = Bidirectional(GRU(self.enc_units,
                          return_sequences=True,
                          return_state=True,
                          recurrent_initializer='glorot_uniform'),
                          merge_mode='concat'
                        )
        self.relu = Dense(self.enc_units, activation='relu') 

还设置了一个带有 ReLU 激活函数的全连接层。这两个层返回它们的隐藏层。然而,解码器和注意力层需要一个隐藏状态向量。我们将隐藏状态通过全连接层,并将维度从 512 转换为 256,这也是解码器和注意力模块所期望的。这完成了编码器类的构造器。鉴于这是一个自定义模型,计算模型的方式很具体,因此定义了一个 call() 方法,该方法操作一批输入以生成输出和隐藏状态。这个方法接收隐藏状态以初始化双向层:

 def call(self, x, hidden):
        x = self.embedding(x)  # We are using a mask
        output, forward_state, backward_state = self.bigru(x, initial_state = hidden)
        # now, concat the hidden states through the dense ReLU layer
        hidden_states = tf.concat([forward_state, backward_state], 
                                  axis=1)
        output_state = self.relu(hidden_states)

        return output, output_state 

首先,输入通过嵌入层传递。输出被送入双向层,随后获取输出和隐藏状态。这两个隐藏状态被连接并通过全连接层处理,最终生成输出隐藏状态。最后,定义一个实用方法以返回初始隐藏状态:

def initialize_hidden_state(self):
        return [tf.zeros((self.batch_size, self.enc_units)) 
                 for i in range(2)] 

这完成了编码器的代码。在进入解码器之前,需要定义一个将在解码器中使用的注意力层。将使用 Bahdanau 的注意力公式。请注意,TensorFlow/Keras 并未提供现成的注意力层。不过,这段简单的注意力层代码应该是完全可复用的。

Bahdanau 注意力层

Bahdanau 等人于 2015 年发布了这种形式的全局注意力机制。正如我们在前面的章节中看到的,它已被广泛应用于 Transformer 模型中。现在,我们将从零开始实现一个注意力层。这部分代码灵感来源于 TensorFlow 团队发布的 NMT 教程。

注意力机制的核心思想是让解码器能够看到所有输入,并在预测输出标记时专注于最相关的输入。全局注意力机制允许解码器看到所有输入。这个全局版本的注意力机制将被实现。从抽象层面来说,注意力机制的目的是将一组值映射到给定的查询上。它通过为每个值提供一个相关性评分,来评估它们对于给定查询的重要性。

在我们的案例中,查询是解码器的隐藏状态,而值是编码器的输出。我们希望找出哪些输入能最好地帮助解码器生成下一个标记。第一步是使用编码器输出和解码器的前一个隐藏状态计算一个分数。如果这是解码的第一步,那么将使用编码器的隐藏状态来初始化解码器。一个对应的权重矩阵与编码器输出和解码器的隐藏状态相乘。输出通过 tanh 激活函数并与另一个权重矩阵相乘,从而生成最终分数。以下方程展示了这一公式:

矩阵 VW[1] 和 W[2] 是可训练的。接下来,为了理解解码器输出和编码器输出之间的对齐关系,计算一个 softmax:

最后一步是生成上下文向量。上下文向量是通过将注意力权重与编码器输出相乘得到的:

这些就是注意力层中的所有计算过程。

第一阶段是为注意力类设置构造函数:

class BahdanauAttention(tf.keras.layers.Layer):
    def __init__(self, units):
        super(BahdanauAttention, self).__init__()
        self.W1 = tf.keras.layers.Dense(units)
        self.W2 = tf.keras.layers.Dense(units)
        self.V = tf.keras.layers.Dense(1) 

BahdanauAttention类的call()方法实现了前面显示的方程式,并附加了一些额外代码来管理张量形状。如下所示:

def call(self, decoder_hidden, enc_output):
    # decoder hidden state shape == (64, 256) 
    # [batch size, decoder units]
    # encoder output shape == (64, 128, 256) 
    # which is [batch size, max sequence length, encoder units]
    query = decoder_hidden # to map our code to generic 
    # form of attention
    values = enc_output

    # query_with_time_axis shape == (batch_size, 1, hidden size)
    # we are doing this to broadcast addition along the time axis
    query_with_time_axis = tf.expand_dims(query, 1)
    # score shape == (batch_size, max_length, 1)
    score = self.V(tf.nn.tanh(
        self.W1(query_with_time_axis) + self.W2(values)))
    # attention_weights shape == (batch_size, max_length, 1)
    attention_weights = tf.nn.softmax(score, axis=1)
    # context_vector shape after sum == (batch_size, hidden_size)
    context_vector = attention_weights * values
    context_vector = tf.reduce_sum(context_vector, axis=1)
    return context_vector, attention_weights 

我们剩下的唯一任务就是实现解码器模型。

解码器模型

下面的图展示了详细的解码器模型:

A screenshot of a cell phone  Description automatically generated

图 6.3:详细的解码器架构

编码器的隐藏状态用于初始化解码器的隐藏状态。起始标记符启动生成摘要的过程。解码器的隐藏状态以及编码器的输出一起用于计算注意力权重和上下文向量。上下文向量与输出标记符的嵌入向量一起拼接,并通过单向 GRU 单元。GRU 单元的输出经过一个密集层,并使用 Softmax 激活函数来获得输出标记符。这个过程是逐个标记符重复进行的。

请注意,解码器在训练和推理过程中有不同的工作方式。在训练时,解码器的输出标记符用于计算损失,但不会反馈回解码器以生成下一个标记符。相反,训练时每一步都会将真实标签中的下一个标记符输入到解码器中。这种过程叫做教师强迫。解码器生成的输出标记符仅在推理时,即生成摘要时,才会被反馈回解码器。

Decoder类定义在seq2seq.py文件中。该类的构造函数设置了维度和各个层:

class Decoder(tf.keras.Model):
    def __init__(self, vocab_size, embedding_dim, dec_units, batch_sz):
        super(Decoder, self).__init__()
        self.batch_sz = batch_sz
        self.dec_units = dec_units
        # Unique embedding layer
        self.embedding = tf.keras.layers.Embedding(vocab_size, 
                                                   embedding_dim,
                                                   mask_zero=True)
        # Shared embedding layer
        # self.embedding = Embedding.get_embedding(vocab_size, 
        # embedding_dim)
        self.gru = tf.keras.layers.GRU(self.dec_units,
                                       return_sequences=True,
                                       return_state=True,
                                       recurrent_initializer=\
                                       'glorot_uniform')
        self.fc1 = tf.keras.layers.Dense(vocab_size, 
                               activation='softmax')
        # used for attention
        self.attention = BahdanauAttention(self.dec_units) 

解码器中的嵌入层与编码器是分开的。这是一个设计选择。在摘要任务中,通常使用共享的嵌入层。Gigaword 数据集中的文章及其摘要结构稍有不同,因为新闻标题并非完整的句子,而是句子的片段。在训练过程中,使用不同的嵌入层比共享嵌入层取得了更好的结果。可能在 CNN/DailyMail 数据集中,使用共享嵌入层会比在 Gigaword 数据集上取得更好的结果。在机器翻译的情况下,编码器和解码器处理的是不同的语言,因此分开的嵌入层是最佳实践。建议你在不同的数据集上尝试两种版本,建立自己的直觉。前面的注释代码使得在编码器和解码器之间轻松切换共享和分开的嵌入层。

解码器的下一部分是计算输出的过程:

def call(self, x, hidden, enc_output):
    # enc_output shape == (batch_size, max_length, hidden_size)
    context_vector, attention_weights = self.attention(hidden,
                                                       enc_output)
    # x shape after passing through embedding
    # == (batch_size, 1, embedding_dim)
    x = self.embedding(x)
    x = tf.concat([tf.expand_dims(context_vector, 1), x], axis=-1)
    # passing the concatenated vector to the GRU
    output, state = self.gru(x)
    output = tf.reshape(output, (-1, output.shape[2]))

    x = self.fc1(output)

    return x, state, attention_weights 

计算过程相对简单。模型如下所示:

Model: "encoder"
_________________________________________________________________
Layer (type)                 Output Shape              Param #
=================================================================
embedding (Embedding)        multiple                  4211072
_________________________________________________________________
bidirectional (Bidirectional multiple                  592896
_________________________________________________________________
dense (Dense)                multiple                  131328
=================================================================
Total params: 4,935,296
Trainable params: 4,935,296
Non-trainable params: 0
_________________________________________________________________
Model: "decoder"
_________________________________________________________________
Layer (type)                 Output Shape              Param #
=================================================================
embedding_1 (Embedding)      multiple                  4211072
_________________________________________________________________
gru_1 (GRU)                  multiple                  689664
_________________________________________________________________
dense_1 (Dense)              multiple                  8455043
_________________________________________________________________
bahdanau_attention (Bahdanau multiple                  197377
=================================================================
Total params: 13,553,156
Trainable params: 13,553,156
Non-trainable params: 0 

编码器模型包含 490 万个参数,而解码器模型包含 1350 万个参数,总共有 1840 万个参数。现在,我们准备好训练模型了。

训练模型

在训练过程中,有一些步骤需要自定义的训练循环。首先,让我们定义一个执行训练循环一步的函数。这个函数定义在s2s-training.py文件中:

@tf.function
def train_step(inp, targ, enc_hidden, max_gradient_norm=5):
    loss = 0

    with tf.GradientTape() as tape:
        # print("inside gradient tape")
        enc_output, enc_hidden = encoder(inp, enc_hidden)

        dec_hidden = enc_hidden
        dec_input = tf.expand_dims([start] * BATCH_SIZE, 1)

        # Teacher forcing - feeding the target as the next input
        for t in range(1, targ.shape[1]):
            # passing enc_output to the decoder
            predictions, dec_hidden, _ = decoder(dec_input,   
                                           dec_hidden, enc_output)

            loss += s2s.loss_function(targ[:, t], predictions)
            # using teacher forcing
            dec_input = tf.expand_dims(targ[:, t], 1)

    batch_loss = (loss / int(targ.shape[1]))

    variables = encoder.trainable_variables + \
decoder.trainable_variables
    gradients = tape.gradient(loss, variables)
    # Gradient clipping
    clipped_gradients, _ = tf.clip_by_global_norm(
                                    gradients, max_gradient_norm)
    optimizer.apply_gradients(zip(clipped_gradients, variables))
    return batch_loss 

这是一个自定义的训练循环,使用GradientTape来跟踪模型的不同变量并计算梯度。前面的函数每处理一个输入批次就执行一次。输入通过编码器(Encoder)进行处理,得到最终的编码和最后的隐藏状态。解码器(Decoder)使用最后一个编码器隐藏状态进行初始化,并且一次生成一个 token 的摘要。然而,生成的 token 不会反馈到解码器中,而是将实际的 token 反馈回去。这种方法被称为教师强制(Teacher Forcing)。自定义损失函数定义在seq2seq.py文件中:

loss_object = tf.keras.losses.SparseCategoricalCrossentropy(
                    from_logits=False, reduction='none')
def loss_function(real, pred):
    mask = tf.math.logical_not(tf.math.equal(real, 0))
    loss_ = loss_object(real, pred)
    mask = tf.cast(mask, dtype=loss_.dtype)
    loss_ *= mask
    return tf.reduce_mean(loss_) 

损失函数的关键在于使用掩码来处理不同长度的摘要。模型的最后部分使用了一个优化器。这里使用的是 Adam 优化器,并且采用了一个学习率调度器,使学习率在训练的各个 epoch 中逐渐减小。学习率退火的概念在之前的章节中有讲解。优化器的代码位于s2s-training.py文件的主函数中:

steps_per_epoch = BUFFER_SIZE // BATCH_SIZE
embedding_dim = 128
units = 256  # from pointer generator paper
EPOCHS = 16

encoder = s2s.Encoder(vocab_size, embedding_dim, units, BATCH_SIZE)
decoder = s2s.Decoder(vocab_size, embedding_dim, units, BATCH_SIZE)
# Learning rate scheduler
lr_schedule = tf.keras.optimizers.schedules.InverseTimeDecay(
                   0.001,
                   decay_steps=steps_per_epoch*(EPOCHS/2),
                   decay_rate=2,
                   staircase=False)
optimizer = tf.keras.optimizers.Adam(lr_schedule) 

由于模型将训练很长时间,因此设置检查点非常重要,这样可以在出现问题时重新启动训练。检查点还为我们提供了一个调整训练参数的机会。主函数的下一部分设置了检查点系统。我们在上一章中看到了检查点的内容。我们将扩展所学内容,并设置一个可选的命令行参数,指定是否需要从特定检查点重新启动训练:

if args.checkpoint is None:
    dt = datetime.datetime.today().strftime("%Y-%b-%d-%H-%M-%S")
    checkpoint_dir = './training_checkpoints-' + dt
else:
    checkpoint_dir = args.checkpoint
checkpoint_prefix = os.path.join(checkpoint_dir, "ckpt")
checkpoint = tf.train.Checkpoint(optimizer=optimizer,
                                 encoder=encoder,
                                 decoder=decoder)
if args.checkpoint is not None:
    # restore last model
    print("Checkpoint being restored: ",
 tf.train.latest_checkpoint(checkpoint_dir))
    chkpt_status = checkpoint.restore(
tf.train.latest_checkpoint(checkpoint_dir))
    # to check loading worked
 chkpt_status.assert_existing_objects_matched()  
else:
    print("Starting new training run from scratch")
print("New checkpoints will be stored in: ", checkpoint_dir) 

如果训练需要从检查点重新开始,那么在调用训练脚本时,可以指定一个命令行参数,形式为–-checkpoint <dir>。如果未提供参数,则会创建一个新的检查点目录。使用 150 万条记录进行训练需要超过 3 小时。运行 10 次迭代将需要超过一天半的时间。本章前面提到的 Pointer-Generator 模型训练了 33 个 epoch,训练时间超过了 4 天。然而,在训练 4 个 epoch 后,已经可以看到一些结果。

现在,主函数的最后部分是开始训练过程:

print("Starting Training. Total number of steps / epoch: ", steps_per_epoch)
    for epoch in range(EPOCHS):
        start_tm = time.time()
        enc_hidden = encoder.initialize_hidden_state()
        total_loss = 0
        for (batch, (art, smry)) in enumerate(train_dataset.take(steps_per_epoch)):
            batch_loss = train_step(art, smry, enc_hidden)
            total_loss += batch_loss
            if batch % 100 == 0:
                ts = datetime.datetime.now().\
strftime("%d-%b-%Y (%H:%M:%S)")
                print('[{}] Epoch {} Batch {} Loss {:.6f}'.\
                        format(ts,epoch + 1, batch,
                        batch_loss.numpy())) # end print
        # saving (checkpoint) the model every 2 epochs
        if (epoch + 1) % 2 == 0:
            checkpoint.save(file_prefix = checkpoint_prefix)
        print('Epoch {} Loss {:.6f}'.\
                format(epoch + 1, total_loss / steps_per_epoch))

        print('Time taken for 1 epoch {} sec\n'.\
                  format(time.time() - start_tm)) 

训练循环每 100 个批次打印一次损失,并且每经过第二个 epoch 保存一次检查点。根据需要,您可以随意调整这些设置。以下命令可以用来开始训练:

$ python s2s-training.py 

这个脚本的输出应该类似于:

Loading the dataset
Tokenizer ready. Total vocabulary size:  32897
Coronavirus spread surprised everyone  =>  [16166, 2342, 1980, 7546, 21092]
16166 ----> corona
2342 ----> virus
1980 ----> spread
7546 ----> surprised
21092 ----> everyone
Dataset sample taken
Dataset batching done
Starting new training run from scratch
New checkpoints will be stored in:  ./training_checkpoints-2021-Jan-04-04-33-42
Starting Training. Total number of steps / epoch:  31
[04-Jan-2021 (04:34:45)] Epoch 1 Batch 0 Loss 2.063991
...
Epoch 1 Loss 1.921176
Time taken for 1 epoch 83.241370677948 sec
[04-Jan-2021 (04:35:06)] Epoch 2 Batch 0 Loss 1.487815
Epoch 2 Loss 1.496654
Time taken for 1 epoch 21.058568954467773 sec 

这个示例运行只使用了 2000 个样本,因为我们编辑了这一行:

BUFFER_SIZE = 2000  # 3500000 takes 7hr/epoch 

如果训练是从检查点重新开始的,则命令行将是:

$ python s2s-trainingo.py --checkpoint training_checkpoints-2021-Jan-04-04-33-42 

通过这个注释,模型从我们在训练步骤中使用的检查点目录中加载。训练从那个点继续。一旦模型完成训练,我们就可以开始生成摘要。请注意,我们将在下一部分使用的模型已经训练了 8 个 epoch,使用了 150 万个记录。如果使用所有 380 万个记录并训练更多的 epoch,结果会更好。

生成摘要

在生成摘要时,关键要注意的是需要构建一个新的推理循环。回想一下,在训练期间使用了教师强迫,并且解码器的输出并未用于预测下一个 token。而在生成摘要时,我们希望使用生成的 token 来预测下一个 token。由于我们希望尝试各种输入文本并生成摘要,因此我们将使用generating-summaries.ipynb IPython 笔记本中的代码。导入并设置好所有内容后,接下来需要实例化 tokenizer。笔记本中的设置分词部分加载 tokenizer,并通过添加开始和结束 token 的 ID 来设置词汇表。类似于我们加载数据时,数据编码方法将被设置为对输入文章进行编码。

现在,我们必须从保存的检查点中加载模型。首先创建所有模型对象:

BATCH_SIZE = 1  # for inference
embedding_dim = 128
units = 256  # from pointer generator paper
vocab_size = end + 2
# Create encoder and decoder objects
encoder = s2s.Encoder(vocab_size, embedding_dim, units, 
                        BATCH_SIZE)
decoder = s2s.Decoder(vocab_size, embedding_dim, units, 
                        BATCH_SIZE)
optimizer = tf.keras.optimizers.Adam() 

然后,定义一个带有适当检查点目录的检查点:

# Hydrate the model from saved checkpoint
checkpoint_dir = 'training_checkpoints-2021-Jan-25-09-26-31'
checkpoint_prefix = os.path.join(checkpoint_dir, "ckpt")
checkpoint = tf.train.Checkpoint(optimizer=optimizer,
                                 encoder=encoder,
                                 decoder=decoder) 

接下来,检查最后一个检查点:

# The last training checkpoint
tf.train.latest_checkpoint(checkpoint_dir) 
'training_checkpoints-2021-Jan-25-09-26-31/ckpt-11' 

由于检查点是在每个间隔 epoch 后存储的,因此这个检查点对应的是 8 个 epoch 的训练。可以使用以下代码加载并测试检查点:

chkpt_status = checkpoint.restore(
                        tf.train.latest_checkpoint(checkpoint_dir))
chkpt_status.assert_existing_objects_matched() 
<tensorflow.python.training.tracking.util.CheckpointLoadStatus at 0x7f603ae03c90> 

就这样!模型现在已经准备好进行推理。

检查点和变量名称

如果第二个命令无法将检查点中的变量名与模型中的变量名匹配,可能会出现错误。这种情况可能发生,因为我们在实例化模型时没有显式命名层。TensorFlow 将在实例化模型时为层动态生成名称:

for layer in decoder.layers:
    print(layer.name) 
embedding_1
gru_1
fc1 

可以使用以下命令检查检查点中的变量名称:

tf.train.list_variables(
       tf.train.latest_checkpoint('./<chkpt_dir>/')
) 

如果模型再次实例化,这些名称可能会发生变化,恢复检查点时可能会失败。为了避免这种情况,有两种解决方案。一种快速的解决方法是重启笔记本内核。更好的解决方法是编辑代码,在训练之前为 Encoder 和 Decoder 构造函数中的每一层添加名称。这可以确保检查点始终能找到变量。以下是为 Decoder 中的fc1层使用这种方法的示例:

self.fc1 = tf.keras.layers.Dense(
                vocab_size, activation='softmax', 
                name='fc1') 

推理可以通过贪心搜索或束搜索算法来完成。这两种方法将在这里演示。在生成摘要的代码之前,将定义一个便捷的方法来绘制注意力权重图。这有助于提供一些直觉,帮助理解哪些输入对生成摘要中的特定 token 有贡献:

# function for plotting the attention weights
def plot_attention(attention, article, summary):
    fig = plt.figure(figsize=(10,10))
    ax = fig.add_subplot(1, 1, 1)
    # https://matplotlib.org/3.1.0/tutorials/colors/colormaps.html 
    # for scales
    ax.matshow(attention, cmap='cividis')
    fontdict = {'fontsize': 14}
    ax.set_xticklabels([''] + article, fontdict=fontdict, rotation=90)
    ax.set_yticklabels([''] + summary, fontdict=fontdict)
    ax.xaxis.set_major_locator(ticker.MultipleLocator(1))
    ax.yaxis.set_major_locator(ticker.MultipleLocator(1))
    plt.show() 

配置了一个图表,其中输入序列作为列,输出摘要词元作为行。可以尝试不同的颜色比例,以更好地理解词元之间关联的强度。

我们已经走了很远的路,可能训练了数小时的网络。是时候看看我们的努力成果了!

贪心搜索

贪心搜索在每个时间步使用概率最高的词元来构造序列。预测的词元被反馈到模型中,以生成下一个词元。这与前一章中生成字符的 char-RNN 模型使用的模型相同:

art_max_len = 128
smry_max_len = 50
def greedy_search(article):
    # To store attention plots of the output
    attention_plot = np.zeros((smry_max_len, art_max_len))
    tokens = tokenizer.encode(article) 
    if len(tokens) > art_max_len:
        tokens = tokens[:art_max_len]
    inputs = sequence.pad_sequences([tokens], padding='post',
                                 maxlen=art_max_len).squeeze()
    inputs = tf.expand_dims(tf.convert_to_tensor(inputs), 0)

    # output summary tokens will be stored in this
    summary = "
    hidden = [tf.zeros((1, units)) for i in range(2)] #BiRNN
    enc_out, enc_hidden = encoder(inputs, hidden)
    dec_hidden = enc_hidden
    dec_input = tf.expand_dims([start], 0)
    for t in range(smry_max_len):
        predictions, dec_hidden, attention_weights = \
decoder(dec_input, dec_hidden, enc_out)
        predicted_id = tf.argmax(predictions[0]).numpy()
        if predicted_id == end:
            return summary, article, attention_plot
        # storing the attention weights to plot later on
        attention_weights = tf.reshape(attention_weights, (-1, ))
        attention_plot[t] = attention_weights.numpy()

        summary += tokenizer.decode([predicted_id])
        # the predicted ID is fed back into the model
        dec_input = tf.expand_dims([predicted_id], 0)
    return summary, article, attention_plot 

代码的第一部分对输入进行与训练时相同的编码。这些输入通过编码器传递到最终的编码器输出和最后的隐藏状态。解码器的初始隐藏状态设置为编码器的最后隐藏状态。现在,生成输出词元的过程开始了。首先,将输入传递给解码器,解码器生成预测、隐藏状态和注意力权重。注意力权重被添加到每个时间步的运行注意力权重列表中。生成过程将继续,直到以下任一情况更早发生:产生序列结束标记,或生成 50 个词元。最终的摘要和注意力图将被返回。定义了一种摘要方法,调用这种贪心搜索算法,绘制注意力权重,并将生成的词元转换成正确的单词:

# Summarize
def summarize(article, algo='greedy'):
    if algo == 'greedy':
        summary, article, attention_plot = greedy_search(article)
    else:
        print("Algorithm {} not implemented".format(algo))
        return

    print('Input: %s' % (article))
    print('** Predicted Summary: {}'.format(summary))
    attention_plot = \
attention_plot[:len(summary.split(' ')), :len(article.split(' '))]
    plot_attention(attention_plot, article.split(' '), 
                     summary.split(' ')) 

前面的方法中有一个地方可以稍后插入束搜索。让我们来测试一下模型:

# Test Summarization
txt = "president georgi parvanov summoned france 's ambassador on wednesday in a show of displeasure over comments from french president jacques chirac chiding east european nations for their support of washington on the issue of iraq ."
summarize(txt.lower()) 
Input: president georgi parvanov summoned france's ambassador on wednesday in a show of displeasure over comments from french president jacques chirac chiding east european nations for their support of washington on the issue of iraq .
** Predicted Summary: **bulgarian president summons french ambassador over remarks on iraq** 

图 6.4:一个示例摘要的注意力图

让我们看看生成的摘要:

保加利亚总统召见法国大使,因其对伊拉克的评论

这是一个相当不错的摘要!最令人惊讶的是,模型能够识别出保加利亚总统,尽管在源文本中没有提到保加利亚。它包含了一些在原文中找不到的词。这些词在前面的输出中已被高亮显示。模型能够将词语召唤的时态从召唤改为召唤。词语评论在源文本中从未出现过。模型能够从多个输入词元中推断出这一点。这个笔记本包含了许多由模型生成的摘要示例,有好的,也有差的。

这是模型面临的一个具有挑战性的文本示例:

  • 输入查尔斯·肯尼迪,英国排名第三的自由民主党领袖,周六宣布他将立即辞职,并且不会参加新的领导选举。美国总统乔治·W·布什周六呼吁延长他第一任期内通过的减税政策,他表示该政策促进了经济增长。

  • 预测的摘要肯尼迪辞职是朝着新任期迈出的第一步

在这篇文章中,有两个看似不相关的句子。模型试图理解它们,但弄得一团糟。还有其他一些例子,模型的表现不太好:

  • 输入JC Penney 将永久关闭另外##家门店。这家上个月申请破产的百货商店连锁,正在朝着关闭##家门店的目标迈进。

  • 预测摘要JC Penney 将关闭另外##家门店,另外#nd 家门店

在这个例子中,模型重复了自己,关注相同的位置。实际上,这是总结模型的一个常见问题。防止重复的一个解决方案是添加覆盖损失。覆盖损失保持跨时间步的注意力权重的累计总和,并将其反馈到注意力机制中,作为提示之前关注过的位置。此外,覆盖损失项被添加到整体损失方程中,以惩罚重复。将模型训练得更久也有助于解决这个特定问题。注意,基于 Transformer 的模型在重复方面的表现稍微好一些。

第二个例子是模型发明了一些东西:

  • 输入德国工程巨头西门子正在改进其有缺陷的电车版本,至今全球售出的###台因技术故障被召回,周二公司女发言人表示。

  • 预测摘要西门子将推出 reb-made 汽车

模型发明了reb-made,这是错误的:

图 6.5:模型发明了“reb-made”这个词

查看前面的注意力图,可以看到新词通过关注改进版本缺陷电车生成。这一发明的词语使得生成的摘要变得混乱。

如前所述,使用束搜索可以进一步提高翻译的准确性。在实现束搜索算法后,我们将尝试一些具有挑战性的例子。

Beam search(束搜索)

束搜索使用多个路径或束来生成标记,并尝试最小化总体条件概率。在每个时间步长,所有选项都会被评估,累计的条件概率也会在所有时间步长中进行评估。只有前k束,其中k是束宽度,会被保留,其余的会在下一时间步长中剪枝。贪心搜索是束搜索的特例,其束宽度为 1。事实上,这个特性为束搜索算法提供了一个测试用例。该部分的代码可以在 IPython 笔记本的束搜索部分找到。

定义了一个名为 beam_search() 的新方法。此方法的第一部分与贪婪搜索相似,输入被分词并通过编码器传递。该算法与贪婪搜索算法的主要区别在于核心循环,它一次处理一个标记。在 Beam search 中,每个 beam 都需要生成一个标记。这使得 Beam search 比贪婪搜索更慢,并且运行时间与 beam 宽度成正比。每个时间步骤,对于每个 k 个 beams,生成最优的 k 个标记,对其进行排序并剪枝至 k 项。这个步骤会持续执行,直到每个 beam 生成结束标记或生成了最大数量的标记。如果需要生成 m 个标记,那么 Beam search 将需要执行 k * m 次解码器的运行,以生成输出序列。主循环代码如下:

# initial beam with (tokens, last hidden state, attn, score)
start_pt = [([start], dec_hidden, attention_plot, 0.0)]  # initial beam 
for t in range(smry_max_len):
    options = list() # empty list to store candidates
    for row in start_pt:
        # handle beams emitting end signal
        allend = True
        dec_input = row[0][-1]
        if dec_input != end_tk:
              # last token
            dec_input = tf.expand_dims([dec_input], 0)  
            dec_hidden = row[1]  # second item is hidden states
            attn_plt = np.zeros((smry_max_len, art_max_len)) +\
                       row[2] # new attn vector

            predictions, dec_hidden, attention_weights = \
decoder(dec_input, dec_hidden, enc_out)
            # storing the attention weights to plot later on
            attention_weights = tf.reshape(attention_weights, (-1, ))
            attn_plt[t] = attention_weights.numpy() 

            # take top-K in this beam
            values, indices = tf.math.top_k(predictions[0],
                                               k=beam_width)
            for tokid, scre in zip(indices, values):
                score = row[3] - np.log(scre)
                options.append((row[0]+[tokid], dec_hidden, 
                                   attn_plt, score))
            allend=False
        else:
            options.append(row)  # add ended beams back in

    if allend:
        break # end for loop as all sequences have ended
    start_pt = sorted(options, key=lambda tup:tup[3])[:beam_width] 

一开始,只有一个 beam 在开始标记内。接着定义了一个列表来跟踪生成的 beams。该元组列表存储注意力图、标记、最后的隐藏状态以及该 beam 的总体代价。条件概率需要所有概率的乘积。由于所有概率值都介于 0 和 1 之间,条件概率可能变得非常小。相反,概率的对数会被相加,如前面的高亮代码所示。最佳的 beams 会最小化这个分数。最后,插入一个小部分,在函数执行完毕后打印所有顶级 beams 及其分数。这个部分是可选的,可以删除:

 if verbose:  # to control output
        # print all the final summaries
        for idx, row in enumerate(start_pt):
            tokens = [x for x in row[0] if x < end_tk]
            print("Summary {} with {:5f}: {}".format(idx, row[3], 
                                        tokenizer.decode(tokens))) 

最终,函数返回最佳的 beam:

 # return final sequence
    summary = tokenizer.decode([x for x in start_pt[0][0] if x < end_tk])
    attention_plot = start_pt[0][2] # third item in tuple
    return summary, article, attention_plot 

summarize() 方法被扩展,因此你可以生成贪婪搜索和 Beam 搜索,示例如下:

# Summarize
def summarize(article, algo='greedy', beam_width=3, verbose=True):
    if algo == 'greedy':
        summary, article, attention_plot = greedy_search(article)
    elif algo=='beam':
        summary, article, attention_plot = beam_search(article, 
                                                beam_width=beam_width,
                                                verbose=verbose)
    else:
        print("Algorithm {} not implemented".format(algo))
        return

    print('Input: %s' % (article))
    print('** Predicted Summary: {}'.format(summary))
    attention_plot = attention_plot[:len(summary.split(' ')), 
                                    :len(article.split(' '))]
    plot_attention(attention_plot, article.split(' '), 
                   summary.split(' ')) 

让我们重新运行西门子有轨电车的示例:

  • 贪婪搜索总结西门子将推出重新制作的汽车

  • Beam search 总结西门子正在研发改进版的欧洲有轨电车

Beam search 生成的总结包含更多细节,并且更好地表示了文本。它引入了一个新词 european,但在当前语境中,这个词的准确性尚不确定。请将下图与之前的注意力图进行对比:

图 6.6:通过 Beam 搜索生成的总结的注意力图

通过 Beam search 生成的总结涵盖了源文本中的更多概念。对于 JC Penney 的示例,Beam search 使输出更好:

  • 贪婪搜索总结JC Penney 将关闭另外一些门店

  • Beam search 总结JC Penney 将关闭更多门店

Beam search 生成的总结更简洁且语法正确。这些示例是使用宽度为 3 的 beam 生成的。该笔记本包含了若干其他示例供你尝试。你会注意到,通常情况下,Beam search 改进了结果,但会减少输出的长度。Beam search 存在一些问题,其中序列的分数没有根据序列长度进行标准化,并且反复关注相同的输入标记没有惩罚。

目前最显著的改进将来自于对模型进行更长时间和更多样本的训练。用于这些示例的模型在 Gigaword 数据集的 380 万样本中训练了 22 个 epoch,使用了 150 万样本。然而,重要的是在提升模型质量时,能随时使用束搜索和各种惩罚方法。

有两个特定的惩罚措施针对这些问题,它们将在下一节中讨论。

使用束搜索的解码惩罚

吴等人于 2016 年在开创性论文Google 的神经机器翻译系统中提出了两种惩罚方法。这些惩罚措施是:

  • 长度归一化:旨在鼓励生成更长或更短的摘要。

  • 覆盖度归一化:旨在惩罚当输出过度集中于输入序列的某一部分时的生成。根据指针生成器论文,最好在训练的最后几轮中加入这一项。此部分不会在本节中实现。

这些方法受 NMT 的启发,必须根据摘要生成的需求进行调整。从高层次来看,分数可以通过以下公式表示:

例如,束搜索算法通常会生成较短的序列。长度惩罚对于 NMT(神经机器翻译)非常重要,因为输出序列应该对应输入文本。这与摘要生成不同,在摘要生成中,更短的输出更受欢迎。长度归一化根据参数和当前的标记数量计算一个因子。束的代价除以这个因子,得到一个长度归一化的分数。论文提出了以下经验公式:

较小的 alpha 值生成较短的序列,较大的值生成较长的序列。值范围为 0 到 1 之间。条件概率分数除以前述值,得到归一化后的束分数。length_wu()方法使用此参数进行分数归一化。

注意,所有与此部分相关的代码都在笔记本中的带有长度归一化的束搜索部分:

def length_wu(step, score, alpha=0.):
    # NMT length re-ranking score from
    # "Google's Neural Machine Translation System" paper by Wu et al
    modifier = (((5 + step) ** alpha) /
                ((5 + 1) ** alpha))
    return (score / modifier) 

在代码中实现起来非常简单。创建了一种新的带有归一化的束搜索方法。大部分代码与之前的实现相同。启用长度归一化的关键变化是,在方法签名中加入一个 alpha 参数,并更新分数的计算方式,以使用上述方法:

# Beam search implementation with normalization
def beam_search_norm(article, beam_width=3, 
                         art_max_len=128, 
                         smry_max_len=50,
                         end_tk=end,
                         alpha=0.,
                         verbose=True) 

接下来,分数会像这样归一化(代码中的大约第 60 行):

 for tokid, scre in zip(indices, values):
            score = row[3] - np.log(scre) 
            score = length_wu(t, score, alpha) 

让我们尝试在一些示例上应用这些设置。首先,我们将尝试在西门子的示例中加入长度归一化惩罚:

  • 贪婪搜索西门子将推出重新制造的汽车

  • 束搜索西门子正在研发改进版的欧洲电车

  • 带有长度惩罚的束搜索西门子正在研发新版有缺陷的电车

生成上述示例时,使用了 5 的束宽和 0.8 的 alpha 值。长度归一化会生成更长的摘要,从而解决了纯粹通过束搜索生成的摘要面临的一些挑战:

手机截图,描述自动生成

图 6.7:带长度归一化的束搜索生成了一个很好的摘要

现在,让我们来看一个更复杂的现代例子,它完全不在训练集当中:

  • 输入英国在周五表示,将允许前往一些低风险国家的国际旅行免于隔离,这些国家在其绿色区域名单上,共计估计##个国家。英国交通部长表示,美国将被划入红色区域

  • 贪心搜索英国将允许低风险国家自由旅行

  • 束搜索英国将允许低风险国家自由旅行

  • 带长度归一化的束搜索英国将允许低风险国家免隔离自由旅行

最佳摘要使用了束搜索和长度归一化。请注意,单独使用束搜索时,去掉了一个非常重要的词——"quarantines"(隔离),使得“自由旅行”前的意思发生了变化。采用长度归一化后,摘要包含了所有正确的细节。请注意,Gigaword 数据集的摘要通常非常简短,而束搜索则使它们变得更短。因此,我们使用了更大的 alpha 值。通常,对于摘要生成使用较小的 alpha 值,而对于 NMT 则使用较大的值。你可以尝试不同的长度归一化参数和束宽值,以建立一些直觉。值得注意的是,长度惩罚的公式是经验性的,也应进行实验。

惩罚项添加了一个新的参数,除了束宽外,还需要调优。选择合适的参数需要比人工检查更好的评估方法,这也是下一节的重点。

摘要评估

当人们写摘要时,他们会使用富有创意的语言。人工编写的摘要通常使用文本中没有出现的词汇。当模型生成抽象摘要时,也可能使用与提供的真实摘要中不同的词汇。实际上,没有一种有效的方法来进行真实摘要和生成摘要的语义比较。在摘要问题中,通常会涉及人工评估步骤,在该步骤中对生成的摘要进行定性检查。这种方法既不具有扩展性,也很昂贵。有一些*似方法使用 n-gram 重叠和最长公共子序列匹配,这些都在词干提取和词形还原后进行。希望这种预处理有助于将真实摘要和生成摘要在评估中拉得更*。评估摘要时最常用的度量是召回导向的概括评估,也称为ROUGE。在机器翻译中,使用的度量包括双语评估替代方法BLEU)和显式排序翻译评估度量METEOR)。BLEU 主要依赖于精确度,因为精确度对翻译非常重要。在摘要中,召回率更为重要。因此,ROUGE 是评估摘要模型的首选度量。它由 Chin-Yew Lin 于 2004 年在一篇名为Rouge: A Package for Automatic Evaluation of Summaries的论文中提出。

ROUGE 度量评估

模型生成的摘要应该是可读的、连贯的,并且事实正确。此外,它还应该语法正确。对摘要进行人工评估可能是一项艰巨的任务。如果一个人需要 30 秒来评估 Gigaword 数据集中的一个摘要,那么一个人需要超过 26 小时来检查验证集。由于正在生成抽象摘要,每次生成摘要时,都需要进行这种人工评估工作。ROUGE 度量试图衡量抽象摘要的各个方面。它是四个度量的集合:

  • ROUGE-N是生成的摘要与真实摘要或参考摘要之间的 n-gram 召回率。名称末尾的"N"指定 n-gram 的长度。通常报告 ROUGE-1 和 ROUGE-2。该度量的计算方法是将生成摘要与真实摘要之间匹配的 n-gram 的比率,除以真实摘要中 n-gram 的总数。此公式侧重于召回率。如果存在多个参考摘要,则对每个参考摘要计算 ROUGE-N 度量,并取最大得分。在我们的示例中,只有一个参考摘要。

  • ROUGE-L使用生成的摘要与参考摘要之间的最长公共子序列LCS)来计算该指标。通常,在计算 LCS 之前会对序列进行词干提取。一旦 LCS 的长度已知,精确度通过将其除以参考摘要的长度来计算;召回率则通过将其除以生成摘要的长度来计算。还会计算并报告 F1 分数,它是精确度和召回率的调和*均值。F1 分数为我们*衡精确度和召回率提供了一种方式。由于 LCS 已经包括了公共的 n-gram,因此不需要选择 n-gram 的长度。此版本的 ROUGE-L 被称为句子级 LCS 分数。还有一个摘要级别的分数,适用于摘要包含多个句子的情况。它用于 CNN 和 DailyMail 数据集等其他数据集。摘要级别的分数通过将参考摘要中的每个句子与所有生成的句子匹配来计算联合 LCS 精确度和召回率。方法的详细信息可以在前述论文中找到。

  • ROUGE-W是前述指标的加权版本,其中 LCS 中的连续匹配被赋予比其他令牌之间有间隔的匹配更高的权重。

  • ROUGE-S使用跳跃二元组共现统计。跳跃二元组允许两个令牌之间存在任意间隙。使用此度量计算精确度和召回率。

提出这些指标的论文还包含了用于计算这些指标的 Perl 代码。这需要生成带有参考的文本文件并生成摘要。Google Research 已经发布了完整的 Python 实现,并可从其 GitHub 仓库获取:github.com/google-research/google-researchrouge/目录包含这些指标的代码。请按照仓库中的安装说明进行操作。安装完成后,我们可以使用 ROUGE-L 指标评估贪心搜索、束搜索和带长度归一化的束搜索来判断它们的质量。此部分的代码位于 ROUGE 评估部分。

可以像下面这样导入并初始化评分库:

from rouge_score import rouge_scorer as rs
scorer = rs.RougeScorer(['rougeL'], use_stemmer=True) 

summarize()方法的一个版本,名为summarize_quietly(),用于在不打印任何输出(如注意力图)的情况下总结文本。将使用验证测试中的随机样本来衡量性能。加载数据和安静总结方法的代码可以在笔记本中找到,并应在运行指标之前执行。评估可以通过贪心搜索来进行,如以下代码片段所示:

# total eval size: 189651
articles = 1000
f1 = 0.
prec = 0.
rec = 0.
beam_width = 1
for art, smm in ds_val.take(articles):
    summ = summarize_quietly(str(art.numpy()), algo='beam-norm', 
                             beam_width=1, verbose=False)
    score = scorer.score(str(smm.numpy()), summ)
    f1 += score['rougeL'].fmeasure / articles
    prec += score['rougeL'].precision / articles
    rec += score['rougeL'].recall / articles
    # see if a sample needs to be printed
    if random.choices((True, False), [1, 99])[0] is True: 
    # 1% samples printed out
        print("Article: ", art.numpy())
        print("Ground Truth: ", smm.numpy())
        print("Greedy Summary: ", summarize_quietly(str(art.numpy()),
              algo='beam-norm', 
              beam_width=1, verbose=False))
        print("Beam Search Summary :", summ, "\n")
print("Precision: {:.6f}, Recall: {:.6f}, F1-Score: {:.6f}".format(prec, rec, f1)) 

验证集包含接* 190,000 条记录,而前面的代码仅在 1,000 条记录上运行指标。该代码还会随机打印约 1%样本的摘要。此评估的结果应该类似于以下内容:

Precision: 0.344725, Recall: 0.249029, F1-Score: 0.266480 

这不是一个坏的开始,因为我们有高精度,但召回率较低。根据 paperswithcode.com 网站,Gigaword 数据集当前的排行榜上最高的 ROUGE-L F1 分数为 36.74。让我们用束搜索(beam search)重新运行相同的测试,看看结果。这里的代码与前面的代码相同,唯一的区别是使用了宽度为 3 的束搜索:

Precision: 0.382001, Recall: 0.226766, F1-Score: 0.260703 

看起来精度大幅提高,但以牺牲召回率为代价。总体而言,F1 分数略有下降。束搜索确实生成了较短的摘要,这可能是召回率下降的原因。调整长度归一化可能有助于解决这个问题。另一种假设是尝试更大的束宽度。尝试使用更大的束宽度 5 产生了这个结果:

Precision: 0.400730, Recall: 0.219472, F1-Score: 0.258531 

精度有了显著提高,但召回率进一步下降。现在,让我们尝试一些长度归一化。使用 alpha 为 0.7 的束搜索给我们带来了以下结果:

Precision: 0.356155, Recall: 0.253459, F1-Score: 0.271813 

通过使用更大的束宽度 5 并保持相同的 alpha,我们得到了这个结果:

Precision: 0.356993, Recall: 0.252384, F1-Score: 0.273171 

召回率有了显著提升,但精度却有所下降。总体来看,对于仅在一部分数据上训练的基础模型,性能相当不错。27.3 的得分将使其跻身排行榜前 20 名。

基于 Seq2seq 的文本摘要方法是 Transformer 模型出现之前的主要方法。现在,基于 Transformer 的模型(包括 Encoder 和 Decoder 部分)被用于摘要生成。下一部分将回顾摘要生成的前沿技术。

摘要生成 — 目前的前沿技术

今天,主流的摘要生成方法使用的是完整的 Transformer 架构。这些模型相当庞大,参数数量从 2.23 亿到 GPT-3 的超过十亿不等。谷歌研究团队在 2020 年 6 月的 ICML 会议上发布了一篇名为 PEGASUS: Pre-training with Extracted Gap-sentences for Abstractive Summarization 的论文。这篇论文为撰写时的最新技术成果设定了基准。这一模型提出的关键创新是专门针对摘要生成的预训练目标。回顾一下,BERT 是使用 掩码语言模型MLM)目标进行预训练的,其中某些 token 会被随机掩码,模型需要预测这些 token。PEGASUS 模型则提出了 Gap Sentence GenerationGSG)预训练目标,其中重要的句子会完全被特殊的掩码 token 替代,模型需要生成该序列。

句子的重点通过与整个文档进行 ROUGE1-F1 分数的比较来判断。某些得分较高的句子会从输入中屏蔽,模型需要预测它们。更多的细节可以在前述论文中找到。基础的 Transformer 模型与 BERT 配置非常相似。预训练目标对 ROUGE1/2/L-F1 分数有显著影响,并在许多数据集上创下新纪录。

这些模型相当庞大,无法在桌面电脑上训练。通常,这些模型会在巨大的数据集上预训练几天。幸运的是,通过像 HuggingFace 这样的库,预训练版本的模型可以使用。

摘要

文本摘要被认为是人类独有的特征。深度学习 NLP 模型在过去的 2-3 年里在这一领域取得了巨大进展。摘要仍然是许多应用中的热门研究领域。在本章中,我们从零开始构建了一个 seq2seq 模型,它可以总结新闻文章中的句子并生成标题。由于其简洁性,该模型取得了相当不错的结果。我们通过学习率衰减,能够长时间训练该模型。通过模型检查点,训练变得更加稳健,因为在出现故障时,可以从上次检查点重新开始训练。训练后,我们通过自定义实现的束搜索提高了生成的摘要质量。由于束搜索倾向于生成简短的摘要,我们使用了长度归一化技术,使得摘要更加完善。

衡量生成摘要的质量是抽象摘要中的一大挑战。这里是来自验证数据集的一个随机示例:

  • 输入: 法国足球明星大卫·吉诺拉于周六代表国际红十字会发起了反地雷运动,该组织将他作为该运动的“代言人”。

  • 真实情况: 足球明星加入红十字会反地雷运动

  • 束搜索 (5/0.7): 前法国足球明星吉诺拉发起反地雷运动

生成的摘要与真实情况非常相似。然而,逐字匹配会给我们一个非常低的分数。使用 n-gram 和 LCS 的 ROUGE 指标可以帮助我们衡量摘要的质量。

最后,我们快速浏览了当前最先进的摘要模型。那些在更大数据集上进行预训练的大型模型已经主导了这一领域。不幸的是,训练如此规模的模型通常超出了单个个人的资源范围。

现在,我们将转向一个全新且令人兴奋的研究领域——多模态网络。到目前为止,我们只单独处理了文本。但一张图片真的是千言万语吗?我们将在下一章尝试给图片添加标题并回答相关问题时,找到答案。

第七章:多模态网络与图像描述生成,使用 ResNet 和 Transformer 网络

“一图胜千言”是一句著名的谚语。在本章中,我们将验证这句谚语,并为图像生成描述。在此过程中,我们将使用多模态网络。到目前为止,我们的输入是文本。人类可以将多种感官输入结合起来,理解周围的环境。我们可以带字幕观看视频并结合所提供的信息来理解场景。我们可以通过面部表情和唇部动作与声音一起理解语言。我们可以在图像中识别文本,并能回答有关图像的自然语言问题。换句话说,我们能够同时处理来自不同模态的信息,并将它们整合在一起理解我们周围的世界。人工智能和深度学习的未来在于构建多模态网络,因为它们能 closely 模拟人类的认知功能。

图像、语音和文本处理的最新进展为多模态网络奠定了坚实的基础。本章将引导你从自然语言处理(NLP)领域过渡到多模态学习领域,我们将使用熟悉的 Transformer 架构结合视觉和文本特征。

本章将涵盖以下主题:

  • 多模态深度学习概述

  • 视觉与语言任务

  • 图像描述任务和 MS-COCO 数据集的详细概述

  • 残差网络架构,特别是 ResNet

  • 使用预训练的 ResNet50 提取图像特征

  • 从零构建完整的 Transformer 模型

  • 提升图像描述生成性能的思路

我们的旅程从视觉理解领域的各种任务概述开始,重点介绍结合语言和图像的任务。

多模态深度学习

“模态”一词的词典定义是“某物存在、体验或表达的特定方式。”感官模态,如触觉、味觉、嗅觉、视觉和听觉,使人类能够体验周围的世界。假设你在农场采摘草莓,朋友告诉你挑选成熟且红的草莓。指令 成熟且红的草莓 会被处理并转化为视觉和触觉标准。当你看到草莓并触摸它们时,你会直觉地知道它们是否符合 成熟且红的 标准。这项任务就是多个模态协同工作来完成一个任务的例子。正如你能想象的,这些能力对机器人学至关重要。

作为前面示例的直接应用,考虑一个需要采摘成熟果实的收割机器人。1976 年 12 月,Harry McGurk 和 John MacDonald 在著名期刊《自然》上发表了一篇题为听嘴唇,看声音的研究论文(www.nature.com/articles/264746a0)。他们录制了一段年轻女性说话的视频,其中ba音节的发音被配上了ga音节的口型。当这个视频播放给成年人时,人们听到的音节是da。而当没有视频只播放音频时,正确的音节被报告了出来。这篇研究论文强调了视觉在语音识别中的作用。使用唇读信息的语音识别模型在视听语音识别AVSR)领域得到了开发。多模态深度学习模型在医疗设备和诊断、学习技术及其他人工智能AI)领域中有许多令人兴奋的应用。

让我们深入探讨视觉与语言的具体互动以及我们可以执行的各种任务。

视觉与语言任务

计算机视觉CV)和自然语言处理NLP)的结合使我们能够构建能够“看”和“说”的智能 AI 系统。CV 和 NLP 的结合为模型开发提供了有趣的任务。给定一张图像并为其生成描述是一个广为人知的任务。该任务的一个实际应用是为网页上的图像生成替代文本标签。视觉障碍读者使用屏幕阅读器来读取这些标签,从而在浏览网页时提高网页的可访问性。该领域的其他话题包括视频描述和讲故事——从一系列图像中编写故事。下图展示了图像和描述的一些示例。本章的主要关注点是图像描述:

一张包含照片、房间、大量物品的图片  描述自动生成

图 7.1:带有描述的示例图像

视觉问答VQA)是一个具有挑战性的任务,旨在回答关于图像中物体的问题。下图展示了来自 VQA 数据集的一些示例。与图像描述不同,图像描述会在描述中体现显著物体,而 VQA 是一个更为复杂的任务。回答问题可能还需要一定的推理。

请看下图右下方的面板。回答问题“这个人视力是 20/20 吗?”需要推理。VQA 的数据集可以在visualqa.org获取:

一人摆姿势拍照  描述自动生成

图 7.2:来自 VQA 数据集的示例(来源:VQA:视觉问答,Agrawal 等人)

推理引出了另一个具有挑战性但又令人着迷的任务——视觉常识推理VCR)。当我们查看一张图像时,我们可以猜测情绪、动作,并推测正在发生的事情。这个任务对人类来说相当简单,甚至可能不需要有意识地努力。VCR 任务的目标是构建能够执行此类任务的模型。这些模型还应能够解释或选择一个适当的理由,来说明已作出的逻辑推理。以下图像展示了 VCR 数据集中的一个示例。有关 VCR 数据集的更多细节,请访问 visualcommonsense.com

社交媒体帖子截图 说明自动生成

图 7.3:VCR 示例(来源:《从识别到认知:视觉常识推理》,Zellers 等人著)

到目前为止,我们已经从图像转向了文本。反过来也可以实现,并且是一个活跃的研究领域。在这个任务中,图像或视频是通过使用生成对抗网络(GANs)和其他生成架构从文本生成的。想象一下,能够根据故事的文本生成一本插画漫画书!这个特定任务目前处于研究的前沿。

该领域的一个关键概念是视觉基础。基础使得将语言中的概念与现实世界相连接。简单来说,就是将词语与图片中的物体相匹配。通过结合视觉和语言,我们可以将语言中的概念与图像中的部分进行对接。例如,将“篮球”这个词与图像中看起来像篮球的物体匹配,这就是视觉基础。也可以有更抽象的概念进行基础化。例如,一只矮小的大象和一个矮小的人具有不同的测量值。基础为我们提供了一种方式来查看模型正在学习的内容,并帮助我们引导它们朝着正确的方向前进。

现在我们已经对视觉和语言任务有了一个正确的视角,让我们深入探讨图像描述任务。

图像描述

图像描述就是用一句话描述图像的内容。描述有助于基于内容的图像检索和视觉搜索。我们已经讨论过,描述如何通过使屏幕阅读器更容易总结图像内容来提高网站的可访问性。描述可以视为图像的总结。一旦我们将问题框定为图像摘要问题,我们可以借用上一章中的 seq2seq 模型来解决这个问题。在文本摘要中,输入是长篇文章的序列,输出是总结内容的简短序列。在图像描述中,输出格式与摘要类似。然而,如何将由像素组成的图像结构化为一系列嵌入,以便输入到编码器中,这可能并不显而易见。

其次,摘要架构使用了双向长短期记忆网络BiLSTMs),其基本原理是相互之间靠得更*的单词在意义上也较为相似。BiLSTMs 通过从两侧查看输入序列并生成编码表示来利用这一特性。为图像生成适合编码器的表示需要一些思考。

一个表示图像为序列的简单解决方案是将其表示为像素列表。因此,一个 28x28 像素的图像就变成了 784 个标记的序列。当这些标记代表文本时,嵌入层学习每个标记的表示。如果这个嵌入层的维度为 64,那么每个标记将通过一个 64 维的向量来表示。这个嵌入向量是在训练过程中学习到的。继续延伸我们使用像素作为标记的类比,一种直接的解决方案是使用图像中每个像素的红/绿/蓝通道值来生成三维嵌入。然而,训练这三个维度似乎并不是一种合乎逻辑的方法。更重要的是,像素在 2D 表示中排布,而文本则是在 1D 表示中排布。这个概念在以下图像中得到了说明。单词与其旁边的单词相关。当像素以序列的形式排列时,这些像素的数据局部性被打破,因为像素的内容与其周围的所有像素相关,而不仅仅是与其左右相邻的像素相关。这个想法通过下面的图像展示出来:

图 7.4:文本与图像中的数据局部性

数据局部性和*移不变性是图像的两个关键特性。*移不变性是指一个物体可以出现在图像的不同位置。在一个全连接模型中,模型会试图学习物体的位置,这会阻止模型的泛化。卷积神经网络CNNs)的专门架构可以用来利用这些特性并从图像中提取信号。总的来说,我们使用 CNNs,特别是ResNet50架构,将图像转换为可以输入到 seq2seq 架构的张量。我们的模型将在 seq2seq 模型下结合 CNNs 和 RNNs 的优势来处理图像和文本部分。以下图示展示了我们架构的高层概述:

图 7.5:高级图像标注模型架构

虽然对 CNNs 的全面解释超出了本书的范围,但我们将简要回顾关键概念。由于我们将使用一个预训练的 CNN 模型,因此无需深入探讨 CNNs 的细节。Python 机器学习(第三版)(由 Packt 出版)是一本阅读 CNNs 的优秀资源。

在上一章的文本摘要中,我们构建了一个带有注意力机制的 seq2seq 模型。在这一章,我们将构建一个 Transformer 模型。Transformer 模型目前是自然语言处理领域的最前沿技术。Transformer 的编码器部分是双向编码器表示(Bidirectional Encoder Representations from Transformers)BERT)架构的核心。Transformer 的解码器部分是生成式预训练 TransformerGPT)系列架构的核心。Transformer 架构有一个在图像字幕生成问题中尤为重要的特定优势。在 seq2seq 架构中,我们使用了 BiLSTM,它尝试通过共现来学习关系。在 Transformer 架构中,没有递归。相反,使用位置编码和自注意力机制来建模输入之间的关系。这一变化使我们能够将处理后的图像补丁作为输入,并希望学习到图像补丁之间的关系。

实现图像字幕生成模型需要大量代码,因为我们将实现多个部分,例如使用 ResNet50 进行图像预处理,并从零开始完整实现 Transformer 架构。本章的代码量远远超过其他章节。我们将依赖代码片段来突出代码中的最重要部分,而不是像以前那样逐行详细讲解代码。

构建模型的主要步骤总结如下:

  1. 下载数据:由于数据集的庞大体积,这是一个耗时的活动。

  2. 预处理字幕:由于字幕是 JSON 格式的,它们被*坦化为 CSV 格式,以便更轻松地处理。

  3. 特征提取:我们通过 ResNet50 将图像文件传递来提取特征,并将其保存,以加速训练。

  4. Transformer 训练:一个完整的 Transformer 模型,包括位置编码、多头注意力机制、编码器和解码器,在处理后的数据上进行训练。

  5. 推理:使用训练好的模型为一些图像生成字幕!

  6. 评估性能:使用双语评估替代法Bilingual Evaluation Understudy,简称BLEU)分数来比较训练模型与真实数据。

让我们首先从数据集开始。

MS-COCO 数据集用于图像字幕生成

微软在 2014 年发布了上下文中的常见物体Common Objects in Context,简称COCO)数据集。所有版本的数据集可以在cocodataset.org上找到。COCO 数据集是一个大型数据集,广泛用于物体检测、分割和字幕生成等任务。我们的重点将放在 2014 年的训练和验证图像上,每个图像都有五个字幕。训练集大约有 83K 张图像,验证集有 41K 张图像。训练和验证图像及字幕需要从 COCO 网站下载。

大文件下载警告:训练集图像数据集大约为 13 GB,而验证集数据集超过 6 GB。图像文件的注释,包括标题,大小约为 214 MB。下载该数据集时,请小心你的网络带宽使用和潜在费用。

Google 还发布了一个新的 Conceptual Captions 数据集,地址为 ai.google.com/research/ConceptualCaptions。它包含超过 300 万张图像。拥有一个大型数据集可以让深度模型更好地训练。还有一个相应的比赛,你可以提交你的模型,看看它与其他模型的表现如何。

鉴于这些是大文件下载,你可能希望使用最适合你的下载方式。如果你的环境中有 wget,你可以使用它来下载文件,方法如下:

$ wget http://images.cocodataset.org/zips/train2014.zip
$ wget http://images.cocodataset.org/zips/val2014.zip
$ wget http://images.cocodataset.org/annotations/annotations_trainval2014.zip 

请注意,训练集和验证集的注释文件是一个压缩包。下载文件后,需要解压。每个压缩文件都会创建一个文件夹,并将内容放入其中。我们将创建一个名为 data 的文件夹,并将所有解压后的内容移动到其中:

$ mkdir data
$ mv train2014 data/
$ mv val2014 data/
$ mv annotations data/ 

所有图像都在 train2014val2014 文件夹中。数据的初步预处理代码位于 data-download-preprocess.py 文件中。训练和验证图像的标题可以在 annotations 子文件夹中的 captions_train2014.jsoncaptions_val2014.json JSON 文件中找到。这两个文件的格式相似。文件中有四个主要键——info、image、license 和 annotation。image 键包含每个图像的记录,以及关于图像的大小、URL、名称和用于引用该图像的唯一 ID。标题以图像 ID 和标题文本的元组形式存储,并带有一个用于标题的唯一 ID。我们使用 Python 的 json 模块来读取和处理这些文件:

valcaptions = json.load(open(
    './data/annotations/captions_val2014.json', 'r'))
trcaptions = json.load(open(
    './data/annotations/captions_train2014.json', 'r'))
# inspect the annotations
print(trcaptions.keys())
dict_keys(['info', 'images', 'licenses', 'annotations']) 

我们的目标是生成一个包含两列的简单文件——一列是图像文件名,另一列是该文件的标题。请注意,验证集包含的图像数量是训练集的一半。在一篇关于图像标题生成的开创性论文《深度视觉-语义对齐用于生成图像描述》中,Andrej Karpathy 和 Fei-Fei Li 提出了在保留 5,000 张验证集图像用于测试后,训练所有的训练集和验证集图像。我们将通过将图像名称和 ID 处理成字典来遵循这种方法:

prefix = "./data/"
val_prefix = prefix + 'val2014/'
train_prefix = prefix + 'train2014/'
# training images
trimages = {x['id']: x['file_name'] for x in trcaptions['images']}
# validation images
# take all images from validation except 5k - karpathy split
valset = len(valcaptions['images']) - 5000 # leave last 5k 
valimages = {x['id']: x['file_name'] for x in valcaptions['images'][:valset]}
truevalimg = {x['id']: x['file_name'] for x in valcaptions['images'][valset:]} 

由于每个图像都有五个标题,验证集不能根据标题进行拆分。否则,会出现训练集数据泄漏到验证集/测试集的情况。在前面的代码中,我们保留了最后 5K 张图像用于验证集。

现在,让我们查看训练集和验证集图像的标题,并创建一个合并的列表。我们将创建空列表来存储图像路径和标题的元组:

# we flatten to (caption, image_path) structure
data = list()
errors = list()
validation = list() 

接下来,我们将处理所有的训练标签:

for item in trcaptions['annotations']:
    if int(item['image_id']) in trimages:
        fpath = train_prefix + trimages[int(item['image_id'])]
        caption = item['caption']
        data.append((caption, fpath))
    else:
        errors.append(item) 

对于验证标签,逻辑类似,但我们需要确保不为已预留的图像添加标签:

for item in valcaptions['annotations']:
    caption = item['caption']
    if int(item['image_id']) in valimages:
        fpath = val_prefix + valimages[int(item['image_id'])]
        data.append((caption, fpath))
    elif int(item['image_id']) in truevalimg: # reserved
        fpath = val_prefix + truevalimg[int(item['image_id'])]
        validation.append((caption, fpath))
    else:
        errors.append(item) 

希望没有任何错误。如果遇到错误,可能是由于下载文件损坏或解压时出错。训练数据集会进行洗牌,以帮助训练。最后,会持久化保存两个 CSV 文件,分别包含训练数据和测试数据:

# persist for future use
with open(prefix + 'data.csv', 'w') as file:
    writer = csv.writer(file, quoting=csv.QUOTE_ALL)
    writer.writerows(data)
# persist for future use
with open(prefix + 'validation.csv', 'w') as file:
    writer = csv.writer(file, quoting=csv.QUOTE_ALL)
    writer.writerows(validation)
print("TRAINING: Total Number of Captions: {},  Total Number of Images: {}".format(
    len(data), len(trimages) + len(valimages)))
print("VALIDATION/TESTING: Total Number of Captions: {},  Total Number of Images: {}".format(
    len(validation), len(truevalimg)))
print("Errors: ", errors) 
TRAINING: Total Number of Captions: 591751,  Total Number of Images: 118287
VALIDATION/TESTING: Total Number of Captions: 25016,  Total Number of Images: 5000
Errors:  [] 

到此为止,数据下载和预处理阶段已经完成。下一步是使用 ResNet50 对所有图像进行预处理,以提取特征。在我们编写相关代码之前,我们将稍作绕行,了解一下 CNN 和 ResNet 架构。如果你已经熟悉 CNN,可以跳过这部分,直接进入代码部分。

使用 CNN 和 ResNet50 进行图像处理

在深度学习的世界里,已经开发出特定的架构来处理特定的模态。CNN 在处理图像方面取得了巨大的成功,并且是计算机视觉任务的标准架构。使用预训练模型提取图像特征的一个很好的思维模型是,将其类比为使用预训练的词向量(如 GloVe)来处理文本。在这个特定的案例中,我们使用一种叫做 ResNet50 的架构。虽然本书并不深入讲解 CNN 的所有细节,但这一节将简要概述 CNN 和 ResNet。如果你已经熟悉这些概念,可以跳到使用 ResNet50 进行图像特征提取这一部分。

CNN(卷积神经网络)

CNN(卷积神经网络)是一种旨在从以下关键特性中学习的架构,这些特性与图像识别相关:

  • 数据局部性:图像中的像素与周围像素高度相关。

  • *移不变性:一个感兴趣的物体,例如鸟,可能出现在图像的不同位置。模型应该能够识别该物体,而不管它在图像中的位置如何。

  • 尺度不变性:感兴趣的物体可能会根据缩放显示为较小或较大的尺寸。理想情况下,模型应该能够识别图像中的物体,而不管它们的尺寸如何。

卷积层和池化层是帮助 CNN 从图像中提取特征的关键组件。

卷积操作

卷积是一种数学运算,它在从图像中提取的区域上执行,使用的是过滤器。过滤器是一个矩阵,通常是方形的,常见的尺寸为 3x3、5x5 和 7x7。下图展示了一个 3x3 卷积矩阵应用于 5x5 图像的例子。图像区域从左到右、从上到下提取。每次步进的像素数被称为步幅。在水*和垂直方向上,步幅为 1 时,会将一个 5x5 的图像缩减为 3x3 的图像,如下所示:

一张绿色屏幕的特写图像 自动生成的描述

图 7.6:卷积操作示例

这里应用的特定滤波器是边缘检测滤波器。在 CNN 出现之前,计算机视觉(CV)在很大程度上依赖于手工制作的滤波器。Sobel 滤波器是用于边缘检测的特殊滤波器之一。convolution-example.ipynb笔记本提供了使用 Sobel 滤波器检测边缘的示例。代码非常简单。在导入模块后,图像文件被加载并转换为灰度图像:

tulip = Image.open("chap7-tulip.jpg") 
# convert to gray scale image
tulip_grey = tulip.convert('L')
tulip_ar = np.array(tulip_grey) 

接下来,我们定义并将 Sobel 滤波器应用到图像中:

# Sobel Filter
kernel_1 = np.array([[1, 0, -1],
                     [2, 0, -2],
                     [1, 0, -1]])        # Vertical edge 
kernel_2 = np.array([[1, 2, 1],
                     [0, 0, 0],
                     [-1, -2, -1]])      # Horizontal edge 
out1 = convolve2d(tulip_ar, kernel_1)    # vertical filter
out2 = convolve2d(tulip_ar, kernel_2)    # horizontal filter
# Create a composite image from the two edge detectors
out3 = np.sqrt(out1**2 + out2**2) 

原始图像及其中间版本如下图所示:

计算机屏幕截图 描述自动生成

图 7.7:使用 Sobel 滤波器进行边缘检测

构建这样的滤波器是非常繁琐的。然而,CNN(卷积神经网络)可以通过将滤波器矩阵视为可学习的参数来学习许多这样的滤波器。CNN 通常会通过数百或数千个这样的滤波器(称为通道)处理一张图像,并将它们堆叠在一起。你可以将每个滤波器视为检测某些特征,如竖直线、水*线、弧形、圆形、梯形等。然而,当多个这样的层组合在一起时,魔法就发生了。堆叠多个层导致了分层表示的学习。理解这一概念的一个简单方法是,想象早期的层学习的是简单的形状,如线条和弧形;中间层学习的是圆形和六边形等形状;顶层学习的是复杂的物体,如停车标志和方向盘。卷积操作是关键创新,它利用数据的局部性并提取出特征,从而实现*移不变性。

这种分层的结果是模型中流动的数据量增加。池化是一种帮助减少通过数据的维度并进一步突出这些特征的操作。

池化

一旦卷积操作的值被计算出来,就可以对图像中的补丁应用池化操作,以进一步集中图像中的信号。最常见的池化形式称为最大池化,并在以下图示中展示。这就像是在一个补丁中取最大值一样简单。

以下图示显示了在不重叠的 2x2 补丁上进行最大池化:

色彩丰富的背景特写 描述自动生成

图 7.8:最大池化操作

另一种池化方法是通过对值进行*均。虽然池化降低了复杂性和计算负担,但它也在一定程度上帮助了尺度不变性。然而,这样的模型有可能会出现过拟合,并且无法很好地泛化。Dropout(丢弃法)是一种有助于正则化的技术,它使得此类模型能够更好地泛化。

使用 dropout 进行正则化

你可能还记得,在之前的章节中,我们在 LSTM 和 BiLSTM 设置中使用了 dropout 设置。dropout 的核心思想如下图所示:

Logo 的特写,描述自动生成

图 7.9:丢弃法

与其将低层的每个单元连接到模型中每个更高层的单元,不如在训练期间随机丢弃一些连接。输入仅在训练期间被丢弃。由于丢弃输入会减少到达节点的总输入,相对于测试/推理时间,需要按丢弃率上调输入,以确保相对大小保持一致。训练期间丢弃一些输入迫使模型从每个输入中学习更多内容,因为它不能依赖于特定输入的存在。这有助于网络对缺失输入的鲁棒性,从而帮助模型的泛化。

这些技术的结合帮助构建了越来越深的网络。随着网络变得越来越深,出现的一个挑战是输入信号在更高层变得非常小。残差连接是一种帮助解决这个问题的技术。

残差连接与 ResNet

直觉上,增加更多层应该能提高性能。更深的网络拥有更多的模型容量,因此它应该能够比浅层网络更好地建模复杂的分布。然而,随着更深的模型的建立,精度出现了下降。由于这种下降甚至出现在训练数据上,因此可以排除过拟合作为可能的原因。随着输入通过越来越多的层,优化器在调整梯度时变得越来越困难,以至于学习在模型中受到抑制。Kaiming He 及其合作者在他们的开创性论文《深度残差学习用于图像识别》中发布了 ResNet 架构。

在理解 ResNet 之前,我们必须理解残差连接。残差连接的核心概念如下图所示。在常规的密集层中,输入首先与权重相乘。然后,添加偏置,这是一个线性操作。输出通过激活函数(如 ReLU),这为层引入了非线性。激活函数的输出是该层的最终输出。

然而,残差连接在线性计算和激活函数之间引入了求和,如下图右侧所示:

手机屏幕截图,描述自动生成

图 7.10:一个概念性的残差连接

请注意,前面的图示仅用于说明残差连接背后的核心概念。在 ResNet 中,残差连接发生在多个模块之间。下图展示了 ResNet50 的基本构建模块,也称为瓶颈设计。之所以称之为瓶颈设计,是因为 1x1 卷积块在将输入传递到 3x3 卷积之前会减少输入的维度。最后一个 1x1 块再次将输入的维度扩大,以便传递到下一层:

键盘的特写  描述自动生成

图 7.11:ResNet50 瓶颈构建模块

ResNet50 由几个这样的模块堆叠而成,共有四个组,每组包含三个到七个模块。BatchNorm(批量归一化)是 Sergey Ioffe 和 Christian Szegedy 在 2015 年发表的论文 Batch Normalization: Accelerating Deep Network Training By Reducing Internal Covariate Shift 中提出的。批量归一化旨在减少从一层输出到下一层输入的输出方差。通过减少这种方差,BatchNorm 起到了类似 L2 正则化的作用,后者通过向损失函数中添加权重大小的惩罚来实现类似的效果。BatchNorm 的主要动机是有效地通过大量层反向传播梯度更新,同时最小化这种更新导致发散的风险。在随机梯度下降中,梯度用于同时更新所有层的权重,假设一层的输出不会影响其他任何层。然而,这并不是一个完全有效的假设。对于一个 n 层网络,计算这个将需要 n 阶梯度,这在计算上是不可行的。相反,使用了批量归一化,它一次处理一个小批量,并通过限制更新来减少权重分布中的这种不必要的变化。它通过在将输出传递到下一层之前进行归一化来实现这一点。

ResNet50 的最后两层是全连接层,将最后一个模块的输出分类为一个物体类别。全面了解 ResNet 是一项艰巨的任务,但希望通过本次关于卷积神经网络(CNN)和 ResNet 的速成课程,您已经获得了足够的背景知识来理解它们的工作原理。鼓励您阅读参考文献和 《深度学习与 TensorFlow 2 和 Keras 第二版》,该书由 Packt 出版,提供了更为详细的内容。幸运的是,TensorFlow 提供了一个预训练的 ResNet50 模型,可以直接使用。在接下来的部分中,我们将使用这个预训练的 ResNet50 模型提取图像特征。

使用 ResNet50 进行图像特征提取

ResNet50 模型是在 ImageNet 数据集上训练的。该数据集包含超过 20,000 个类别的数百万张图片。大规模视觉识别挑战赛 ILSVRC 主要聚焦于 1,000 个类别,模型们在这些类别上进行图像识别竞赛。因此,执行分类的 ResNet50 顶层具有 1,000 的维度。使用预训练的 ResNet50 模型的想法是,它已经能够解析出在图像描述中可能有用的对象。

tensorflow.keras.applications 包提供了像 ResNet50 这样的预训练模型。写本文时,所有提供的预训练模型都与计算机视觉(CV)相关。加载预训练模型非常简单。此部分的所有代码都位于本章 GitHub 文件夹中的 feature-extraction.py 文件中。使用单独文件的主要原因是,它让我们能够以脚本的方式运行特征提取。

考虑到我们将处理超过 100,000 张图片,这个过程可能需要一段时间。卷积神经网络(CNN)在计算上受益于 GPU。现在让我们进入代码部分。首先,我们必须设置从上一章的 JSON 注释中创建的 CSV 文件的路径:

prefix = './data/'
save_prefix = prefix + "features/"  # for storing prefixes
annot = prefix + 'data.csv'
# load the pre-processed file
inputs = pd.read_csv(annot, header=None, names=["caption", "image"]) 

ResNet50 期望每张图像为 224x224 像素,并具有三个颜色通道。来自 COCO 数据集的输入图像具有不同的尺寸。因此,我们必须将输入文件转换为 ResNet 训练时使用的标准:

# We are going to use the last residual block of # the ResNet50 architecture
# which has dimension 7x7x2048 and store into individual file
def load_image(image_path, size=(224, 224)):
    # pre-processes images for ResNet50 in batches 
    image = tf.io.read_file(image_path)
    image = tf.io.decode_jpeg(image, channels=3)
    image = tf.image.resize(image, size)
    image = **preprocess_input(image)**  # from keras.applications.ResNet50
    return image, image_path 

高亮显示的代码展示了 ResNet50 包提供的一个特殊预处理函数。输入图像中的像素通过 decode_jpeg() 函数加载到数组中。每个像素在每个颜色通道的值介于 0 和 255 之间。preprocess_input() 函数将像素值归一化,使其均值为 0。由于每张输入图像都有五个描述,我们只应处理数据集中独特的图像:

uniq_images = sorted(inputs['image'].unique())  
print("Unique images: ", len(uniq_images))  # 118,287 images 

接下来,我们必须将数据集转换为 tf.dat.Dataset,这样可以更方便地批量处理和使用之前定义的便捷函数处理输入文件:

image_dataset = tf.data.Dataset.from_tensor_slices(uniq_images)
image_dataset = image_dataset.map(
    load_image, num_parallel_calls=tf.data.experimental.AUTOTUNE).batch(16) 

为了高效地处理和生成特征,我们必须一次处理 16 张图片。下一步是加载一个预训练的 ResNet50 模型:

rs50 = tf.keras.applications.ResNet50(
    include_top=False,
    weights="imagenet", 
    input_shape=(224, 224, 3)
)
new_input = rs50.input
hidden_layer = rs50.layers[-1].output
features_extract = tf.keras.Model(new_input, hidden_layer)
features_extract.summary() 
__________________________________________________________________
Layer (type)                    Output Shape         Param #     Connected to
==================================================================
input_1 (InputLayer)            [(None, 224, 224, 3) 0
__________________________________________________________________
<CONV BLOCK 1>
__________________________________________________________________
<CONV BLOCK 2>
__________________________________________________________________
<CONV BLOCK 3>
__________________________________________________________________
<CONV BLOCK 4>
__________________________________________________________________
<CONV BLOCK 5>
==================================================================
Total params: 23,587,712
Trainable params: 23,534,592
Non-trainable params: 53,120
__________________________________________________________________ 

上述输出已简化以节省篇幅。该模型包含超过 2300 万个可训练参数。我们不需要顶层的分类层,因为我们使用该模型进行特征提取。我们定义了一个新的模型,包括输入和输出层。在这里,我们取了最后一层的输出。我们也可以通过改变 hidden_layer 变量的定义来从 ResNet 的不同部分获取输出。实际上,这个变量可以是一个层的列表,在这种情况下,features_extract 模型的输出将是列表中每一层的输出。

接下来,必须设置一个目录来存储提取的特征:

save_prefix = prefix + "features/"
try:
    # Create this directory 
    os.mkdir(save_prefix)
except FileExistsError:
    pass # Directory already exists 

特征提取模型可以处理图像批次并预测输出。每张图像的输出为 2,048 个 7x7 像素的补丁。如果输入的是 16 张图像的批次,那么模型的输出将是一个[16, 7, 7, 2048]维度的张量。我们将每张图像的特征存储为单独的文件,同时将维度展*为[49, 2048]。每张图像现在已经被转换成一个包含 49 个像素的序列,嵌入大小为 2,048。以下代码执行此操作:

for img, path in tqdm(image_dataset):
    batch_features = features_extract(img)
    batch_features = tf.reshape(batch_features,
                                (batch_features.shape[0], -1,                                  batch_features.shape[3]))
    for feat, p in zip(batch_features, path):
        filepath = p.numpy().decode("utf-8")
        filepath = save_prefix + filepath.split('/')[-1][:-3] + "npy"
        np.save(filepath, feat.numpy())
print("Images saved as npy files") 

这可能是一个耗时的操作,具体取决于你的计算环境。在我的 Ubuntu Linux 系统和 RTX 2070 GPU 的配置下,这个过程花费了大约 23 分钟。

数据预处理的最后一步是训练子词编码器。你应该对这部分非常熟悉,因为它与我们在前几章中做的完全相同:

# Now, read the labels and create a subword tokenizer with it
# ~8K vocab size
cap_tokenizer = tfds.features.text.SubwordTextEncoder.build_from_corpus(
    inputs['caption'].map(lambda x: x.lower().strip()).tolist(),
    target_vocab_size=2**13, reserved_tokens=['<s>', '</s>'])
cap_tokenizer.save_to_file("captions") 

请注意,我们包含了两个特殊的标记来表示序列的开始和结束。你可能会回忆起在第五章中,使用 RNN 和 GPT-2 生成文本时提到过这种技术。在这里,我们采用了稍微不同的方式来实现相同的技术,目的是展示如何用不同的方式实现相同的目标。

到此为止,预处理和特征提取已经完成。接下来的步骤是定义 Transformer 模型。然后,我们就可以开始训练模型了。

Transformer 模型

Transformer 模型在第四章中有讨论,使用 BERT 进行迁移学习。它的灵感来源于 seq2seq 模型,并包含了编码器(Encoder)和解码器(Decoder)部分。由于 Transformer 模型不依赖于 RNN,输入序列需要使用位置编码进行标注,这使得模型能够学习输入之间的关系。移除循环结构显著提高了模型的速度,并减少了内存占用。Transformer 模型的这一创新使得像 BERT 和 GPT-3 这样的大型模型成为可能。Transformer 模型的编码器部分已在前述章节中展示。完整的 Transformer 模型已在第五章使用 RNN 和 GPT-2 生成文本中展示。我们将从一个修改版的完整 Transformer 模型开始。具体来说,我们将修改 Transformer 的编码器部分,创建一个视觉编码器,该编码器以图像数据作为输入,而不是文本序列。为了适应图像作为输入,编码器需要做一些其他小的修改。我们将要构建的 Transformer 模型在下图中展示。这里的主要区别在于输入序列的编码方式。在文本的情况下,我们将使用子词编码器对文本进行分词,并将其通过一个可训练的嵌入层。

随着训练的进行,标记的嵌入(embeddings)也在不断学习。在图像描述的情况下,我们将图像预处理为一个由 49 个像素组成的序列,每个像素的“嵌入”大小为 2,048。这样实际上简化了输入的填充处理。所有图像都经过预处理,使其具有相同的长度。因此,不需要填充和掩码输入:

一张手机的截图,自动生成的描述

图 7.12:带有视觉编码器的 Transformer 模型

以下代码段需要实现才能构建 Transformer 模型:

  • 输入的位置信息编码,以及输入和输出的掩码。我们的输入是固定长度的,但输出和描述是可变长度的。

  • 缩放点积注意力和多头注意力,使编码器和解码器能够专注于数据的特定方面。

  • 一个由多个重复块组成的编码器。

  • 一个使用编码器输出的解码器,通过其重复块进行操作。

Transformer 的代码来自于 TensorFlow 教程,标题为 Transformer 模型用于语言理解。我们将使用这段代码作为基础,并将其适配于图像描述的应用场景。Transformer 架构的一个优点是,如果我们能将问题转化为序列到序列的问题,就可以应用 Transformer 模型。在描述实现过程时,代码的主要要点将被突出展示。请注意,本节的代码位于 visual_transformer.py 文件中。

实现完整的 Transformer 模型确实需要一些代码。如果你已经熟悉 Transformer 模型,或者只是想了解我们的模型与标准 Transformer 模型的不同之处,请专注于下一节和 VisualEncoder 部分。其余部分可以在你有空时阅读。

位置编码和掩码

Transformer 模型不使用 RNN。这使得它们能够在一步计算中得出所有输出,从而显著提高了速度,并且能够学习长输入之间的依赖关系。然而,这也意味着模型无法了解相邻单词或标记之间的关系。为了弥补缺乏关于标记顺序的信息,使用了位置编码向量,其中包含奇数和偶数位置的值,帮助模型学习输入位置之间的关系。

嵌入帮助将含义相似的标记放置在嵌入空间中彼此靠*的位置。位置编码根据标记在句子中的位置将标记相互靠*。将两者结合使用非常强大。

在图像描述中,这对描述是非常重要的。从技术上讲,对于图像输入,我们不需要提供这些位置编码,因为 ResNet50 应该已经生成了适当的补丁。然而,位置编码仍然可以用于输入。位置编码对于偶数位置使用sin函数,对于奇数位置使用cos函数。计算某个位置编码的公式如下:

这里,w[i]定义为:

在前面的公式中,pos表示给定标记的位置,d[model]表示嵌入的维度,i是正在计算的特定维度。位置编码过程为每个标记生成与嵌入相同维度的向量。你可能会想,为什么要使用这么复杂的公式来计算这些位置编码?难道只是从一端到另一端编号标记不就行了吗?事实证明,位置编码算法必须具备一些特性。首先,数值必须能够轻松地推广到长度可变的序列。使用简单的编号方案会导致输入的序列长度超过训练数据时无法处理。此外,输出应该对每个标记的位置是唯一的。而且,不同长度的输入序列中,任何两个位置之间的距离应该保持一致。这种公式相对简单易实现。相关的代码在文件中的位置编码器部分。

首先,我们必须计算角度,如前面的w[i]公式所示,如下所示:

def get_angles(pos, i, d_model):
    angle_rates = 1 / np.power(10000, (2 * (i // 2)) / np.float32(d_model))
    return pos * angle_rates 

然后,我们必须计算位置编码的向量:

def positional_encoding(position, d_model):
    angle_rads = get_angles(np.arange(position)[:, np.newaxis],
                            np.arange(d_model)[np.newaxis, :],
                            d_model)
    # apply sin to even indices in the array; 2i
    angle_rads[:, 0::2] = np.sin(angle_rads[:, 0::2])
    # apply cos to odd indices in the array; 2i+1
    angle_rads[:, 1::2] = np.cos(angle_rads[:, 1::2])
    pos_encoding = angle_rads[np.newaxis, ...]
    return tf.cast(pos_encoding, dtype=tf.float32) 

下一步是计算输入和输出的掩码。让我们暂时关注解码器。由于我们没有使用 RNN,整个输出一次性传入解码器。然而,我们不希望解码器看到未来时间步的数据。所以,输出必须被掩蔽。就编码器而言,如果输入被填充到固定长度,则需要掩码。然而,在我们的情况下,输入总是正好是 49 的长度。所以,掩码是一个固定的全 1 向量:

def create_padding_mask(seq):
    seq = tf.cast(tf.math.equal(seq, 0), tf.float32)
    # add extra dimensions to add the padding
    # to the attention logits.
    return seq[:, tf.newaxis, tf.newaxis, :]  
    # (batch_size, 1, 1, seq_len)
# while decoding, we dont have recurrence and dont want Decoder
# to see tokens from the future
def create_look_ahead_mask(size):
    mask = 1 - tf.linalg.band_part(tf.ones((size, size)), -1, 0)
    return mask  # (seq_len, seq_len) 

第一个方法用于掩盖填充的输入。这个方法是为了完整性而包括的,但稍后你会看到我们传递给它的是一个由 1 组成的序列。所以,这个方法的作用只是重新调整掩码。第二个掩码函数用于掩蔽解码器的输入,使其只能看到自己生成的位置。

转换器的编码器和解码器层使用特定形式的注意力机制。这是架构的基本构建块,接下来将进行实现。

缩放点积和多头注意力

注意力函数的目的是将查询与一组键值对进行匹配。输出是值的加权和,权重由查询和键之间的匹配度决定。多头注意力学习多种计算缩放点积注意力的方式,并将它们结合起来。

缩放点积注意力通过将查询向量与键向量相乘来计算。这个乘积通过查询和键的维度的*方根进行缩放。注意,这个公式假设键和查询向量具有相同的维度。实际上,查询、键和值向量的维度都设置为嵌入的大小。

在位置编码中,这被称为d[model]。在计算键和值向量的缩放点积之后,应用 softmax,softmax 的结果再与值向量相乘。使用掩码来遮盖查询和键的乘积。

def scaled_dot_product_attention(q, k, v, mask):
    # (..., seq_len_q, seq_len_k)
    matmul_qk = tf.matmul(q, k, transpose_b=True)
    # scale matmul_qk
    dk = tf.cast(tf.shape(k)[-1], tf.float32)
    scaled_attention_logits = matmul_qk / tf.math.sqrt(dk)
    # add the mask to the scaled tensor.
    if mask is not None:
        scaled_attention_logits += (mask * -1e9)
    # softmax is normalized on the last axis (seq_len_k)     # so that the scores
    # add up to 1.
    attention_weights = tf.nn.softmax(
                        scaled_attention_logits,
                        axis=-1)  # (..., seq_len_q, seq_len_k)
    output = tf.matmul(attention_weights, v)  
    # (..., seq_len_q, depth_v)
    return output, attention_weights 

多头注意力将来自多个缩放点积注意力单元的输出进行拼接,并通过一个线性层传递。嵌入输入的维度会被头数除以,用于计算键和值向量的维度。多头注意力实现为自定义层。首先,我们必须创建构造函数:

class MultiHeadAttention(tf.keras.layers.Layer):
    def __init__(self, d_model, num_heads):
        super(MultiHeadAttention, self).__init__()
        self.num_heads = num_heads
        self.d_model = d_model
        **assert** **d_model % self.num_heads ==** **0**
        self.depth = d_model // self.num_heads
        self.wq = tf.keras.layers.Dense(d_model)
        self.wk = tf.keras.layers.Dense(d_model)
        self.wv = tf.keras.layers.Dense(d_model)
        self.dense = tf.keras.layers.Dense(d_model) 

请注意,突出显示的assert语句。当实例化 Transformer 模型时,选择某些参数至关重要,以确保头数能够完全除尽模型大小或嵌入维度。此层的主要计算在call()函数中:

 def call(self, v, k, q, mask):
        batch_size = tf.shape(q)[0]
        q = self.wq(q)  # (batch_size, seq_len, d_model)
        k = self.wk(k)  # (batch_size, seq_len, d_model)
        v = self.wv(v)  # (batch_size, seq_len, d_model)
        # (batch_size, num_heads, seq_len_q, depth)
        **q = self.split_heads(q, batch_size)**
        # (batch_size, num_heads, seq_len_k, depth)
        **k = self.split_heads(k, batch_size)**
        # (batch_size, num_heads, seq_len_v, depth)
        **v = self.split_heads(v, batch_size)**
        # scaled_attention.shape == (batch_size, num_heads, seq_len_q, depth)
        # attention_weights.shape == (batch_size, num_heads, seq_len_q, seq_len_k)
        scaled_attention, attention_weights = scaled_dot_product_attention(q, k, v, mask)
        # (batch_size, seq_len_q, num_heads, depth)
        scaled_attention = tf.transpose(scaled_attention, 
                                                   perm=[0, 2, 1, 3])
        concat_attention = tf.reshape(scaled_attention,
                                        (batch_size, -1,
                              self.d_model))  
        # (batch_size, seq_len_q, d_model)
        # (batch_size, seq_len_q, d_model)
        output = self.dense(concat_attention)
        return output, attention_weights 

这三行突出显示了如何将向量分割成多个头。split_heads()定义如下:

 def split_heads(self, x, batch_size):
        """
        Split the last dimension into (num_heads, depth).
        Transpose the result such that the shape is (batch_size, num_heads, seq_len, depth)
        """
        x = tf.reshape(x, (batch_size, -1, 
self.num_heads, self.depth))
        return tf.transpose(x, perm=[0, 2, 1, 3]) 

这完成了多头注意力的实现。这是 Transformer 模型的关键部分。这里有一个关于 Dense 层的小细节,Dense 层用于聚合来自多头注意力的输出。它非常简单:

def point_wise_feed_forward_network(d_model, dff):
    return tf.keras.Sequential([
        # (batch_size, seq_len, dff)
        tf.keras.layers.Dense(dff, activation='relu'),
        tf.keras.layers.Dense(d_model)
        # (batch_size, seq_len, d_model)
    ]) 

到目前为止,我们已经查看了指定 Transformer 模型的以下参数:

  • d[model]用于嵌入的大小和输入的主要流动

  • d[ff]是前馈部分中中间 Dense 层输出的大小

  • h指定了多头注意力的头数

接下来,我们将实现一个视觉编码器,该编码器已经被修改为支持图像作为输入。

VisualEncoder

Transformer 模型部分中展示的图表显示了编码器的结构。编码器通过位置编码和掩码处理输入,然后将其通过多头注意力和前馈层的堆栈进行传递。这个实现偏离了 TensorFlow 教程,因为教程中的输入是文本。在我们的案例中,我们传递的是 49x2,048 的向量,这些向量是通过将图像传入 ResNet50 生成的。主要的区别在于如何处理输入。VisualEncoder被构建为一个层,以便最终组成 Transformer 模型:

class VisualEncoder(tf.keras.layers.Layer):
    def __init__(self, num_layers, d_model, num_heads, dff,
                 maximum_position_encoding=**49**, dropout_rate=0.1,
                 use_pe=True):
        # we have 7x7 images from ResNet50, 
        # and each pixel is an input token
        # which has been embedded into 2048 dimensions by ResNet
        super(VisualEncoder, self).__init__()
        self.d_model = d_model
        self.num_layers = num_layers

        **# FC layer replaces embedding layer in traditional encoder**
        **# this FC layers takes 49x2048 image** 
        **# and projects into model dims**
        **self.fc = tf.keras.layers.Dense(d_model, activation=****'relu'****)**
        self.pos_encoding = positional_encoding(
                                         maximum_position_encoding,
                                      self.d_model)
        self.enc_layers = [EncoderLayer(d_model, num_heads, 
                                        dff, dropout_rate)
                           for _ in range(num_layers)]
        self.dropout = tf.keras.layers.Dropout(dropout_rate)

        self.use_pe = use_pe 

构造函数如下所示。引入了一个新的参数,用于指示层数。原始论文使用了 6 层,512 作为d[模型],8 个多头注意力头,以及 2,048 作为中间前馈输出的大小。请注意前面代码中标记的行。预处理过的图像的尺寸可能会有所不同,具体取决于从 ResNet50 中提取输出的层。我们将输入通过一个稠密层fc,调整为模型所需的输入大小。这使得我们可以在不改变架构的情况下,尝试使用不同的模型来预处理图像,比如 VGG19 或 Inception。还要注意,最大位置编码被硬编码为 49,因为那是 ResNet50 模型输出的维度。最后,我们添加了一个标志位,可以在视觉编码器中开启或关闭位置编码。你应该尝试训练包含或不包含位置编码的模型,看看这是否有助于或阻碍学习。

VisualEncoder由多个多头注意力和前馈块组成。我们可以利用一个方便的类EncoderLayer来定义这样一个块。根据输入参数创建这些块的堆叠。我们稍后会检查EncoderLayer的内部实现。首先,让我们看看输入如何通过VisualEncoder传递。call()函数用于生成给定输入的输出:

 def call(self, x, training, mask):
        # all inp image sequences are always 49, so mask not needed
        seq_len = tf.shape(x)[1]
        # adding embedding and position encoding.
        # input size should be batch_size, 49, 2048)
        # output dims should be (batch_size, 49, d_model)
        x = self.fc(x)
        # scaled dot product attention
        x *= tf.math.sqrt(tf.cast(self.d_model, tf.float32)) 
        if self.use_pe:
            x += self.pos_encoding[:, :seq_len, :]
        x = self.dropout(x, training=training)
        for i in range(self.num_layers):
            x = self.enc_layersi  # mask shouldnt be needed
        return x  # (batch_size, 49, d_model) 

由于之前定义的抽象,这段代码相当简单。请注意使用训练标志来开启或关闭 dropout。现在,让我们看看EncoderLayer是如何定义的。每个 Encoder 构建体由两个子块组成。第一个子块将输入传递通过多头注意力,而第二个子块则将第一个子块的输出通过 2 层前馈层:

class EncoderLayer(tf.keras.layers.Layer):
    def __init__(self, d_model, num_heads, dff, rate=0.1):
        super(EncoderLayer, self).__init__()
        self.mha = MultiHeadAttention(d_model, num_heads)
        self.ffn = point_wise_feed_forward_network(d_model, dff)
        self.layernorm1 = tf.keras.layers.LayerNormalization(
                                                        epsilon=1e-6)
        self.layernorm2 = tf.keras.layers.LayerNormalization(
                                                        epsilon=1e-6)
        self.dropout1 = tf.keras.layers.Dropout(rate)
        self.dropout2 = tf.keras.layers.Dropout(rate)
    def call(self, x, training, mask):
        # (batch_size, input_seq_len, d_model)
        attn_output, _ = self.mha(x, x, x, mask)
        attn_output = self.dropout1(attn_output, 
                                      training=training)
        # (batch_size, input_seq_len, d_model)
        **out1 = self.layernorm1(x + attn_output)** **# Residual connection**

        # (batch_size, input_seq_len, d_model)
        ffn_output = self.ffn(out1)  
        ffn_output = self.dropout2(ffn_output, training=training)
        # (batch_size, input_seq_len, d_model)
        **out2 = self.layernorm2(out1 + ffn_output)** **# Residual conx**
        return out2 

每一层首先通过多头注意力计算输出,并将其传递通过 dropout。一个残差连接将输出和输入的和通过 LayerNorm。该块的第二部分将第一层 LayerNorm 的输出通过前馈层和另一个 dropout 层。

同样,一个残差连接将输出和输入合并,并传递给前馈部分,再通过 LayerNorm。请注意,dropout 和残差连接的使用,这些都是在 Transformer 架构中为计算机视觉(CV)任务开发的。

层归一化或 LayerNorm

LayerNorm 于 2016 年在一篇同名论文中提出,作为 RNN 的 BatchNorm 替代方案。如CNNs部分所述,BatchNorm 会在整个批次中对输出进行归一化。但是在 RNN 的情况下,序列长度是可变的。因此,需要一种不同的归一化公式来处理可变长度的序列。LayerNorm 会在给定层的所有隐藏单元之间进行归一化。它不依赖于批次大小,并且对给定层中的所有单元进行相同的归一化。LayerNorm 显著加快了训练和 seq2seq 模型的收敛速度。

有了VisualEncoder,我们就准备好实现解码器,然后将这一切组合成完整的 Transformer。

解码器

解码器也由块组成,和编码器一样。然而,解码器的每个块包含三个子模块,如Transformer 模型部分中的图所示。首先是掩蔽多头注意力子模块,接着是多头注意力块,最后是前馈子模块。前馈子模块与编码器子模块相同。我们必须定义一个可以堆叠的解码器层来构建解码器。其构造器如下所示:

class DecoderLayer(tf.keras.layers.Layer):
    def __init__(self, d_model, num_heads, dff, rate=0.1):
        super(DecoderLayer, self).__init__()
        self.mha1 = MultiHeadAttention(d_model, num_heads)
        self.mha2 = MultiHeadAttention(d_model, num_heads)
        self.ffn = point_wise_feed_forward_network(d_model, dff)
        self.layernorm1 = tf.keras.layers.LayerNormalization(
                                                       epsilon=1e-6)
        self.layernorm2 = tf.keras.layers.LayerNormalization(
                                                       epsilon=1e-6)
        self.layernorm3 = tf.keras.layers.LayerNormalization(
                                                       epsilon=1e-6)
        self.dropout1 = tf.keras.layers.Dropout(rate)
        self.dropout2 = tf.keras.layers.Dropout(rate)
        self.dropout3 = tf.keras.layers.Dropout(rate) 

基于前面的变量,三个子模块应该是相当明显的。输入通过这一层,并根据call()函数中的计算转换为输出:

 def call(self, x, enc_output, training,
             look_ahead_mask, padding_mask):
        # enc_output.shape == (batch_size, input_seq_len, d_model)
        **attn1, attn_weights_block1 = self.mha1(**
            **x, x, x, look_ahead_mask)**
        # args ^ => (batch_size, target_seq_len, d_model)
        attn1 = self.dropout1(attn1, training=training)
        out1 = self.layernorm1(attn1 + x) # residual
        attn2, attn_weights_block2 = self.mha2(
            enc_output, enc_output, out1, padding_mask)  
        # args ^ =>  (batch_size, target_seq_len, d_model)
        attn2 = self.dropout2(attn2, training=training)
        # (batch_size, target_seq_len, d_model)
        out2 = self.layernorm2(attn2 + out1)
        ffn_output = self.ffn(out2)  
        ffn_output = self.dropout3(ffn_output, training=training)
        # (batch_size, target_seq_len, d_model)
        out3 = self.layernorm3(ffn_output + out2)
        return out3, attn_weights_block1, attn_weights_block2 

第一个子模块,也称为掩蔽多头注意力模块,使用输出令牌,并对当前生成的位置进行掩蔽。在我们的例子中,输出是组成标题的令牌。前瞻掩蔽将未生成的令牌进行掩蔽。

注意,这个子模块不使用编码器的输出。它试图预测下一个令牌与之前生成的令牌之间的关系。第二个子模块使用编码器的输出以及前一个子模块的输出生成输出。最后,前馈网络通过对第二个子模块的输出进行处理来生成最终输出。两个多头注意力子模块都有自己的注意力权重。

我们将解码器定义为一个由多个DecoderLayer块组成的自定义层。Transformer 的结构是对称的。编码器和解码器的块数是相同的。构造器首先被定义:

class Decoder(tf.keras.layers.Layer):
    def __init__(self, num_layers, d_model, num_heads, 
                 dff, target_vocab_size,
                 maximum_position_encoding, rate=0.1):
        super(Decoder, self).__init__()
        self.d_model = d_model
        self.num_layers = num_layers
        self.embedding = tf.keras.layers.Embedding(
                                        target_vocab_size, d_model)
        self.pos_encoding = positional_encoding(
                                maximum_position_encoding, 
                                  d_model)
        self.dec_layers = [DecoderLayer(d_model, num_heads, 
                                           dff, rate)
                           for _ in range(num_layers)]
        self.dropout = tf.keras.layers.Dropout(rate) 

解码器的输出是通过call()函数计算的:

 def call(self, x, enc_output, training,
             look_ahead_mask, padding_mask):
        seq_len = tf.shape(x)[1]
        attention_weights = {}
        x = self.embedding(x)  
        x *= tf.math.sqrt(tf.cast(self.d_model, tf.float32))
        x += self.pos_encoding[:, :seq_len, :]
        x = self.dropout(x, training=training)
        for i in range(self.num_layers):
            x, block1, block2 = self.dec_layersi
        attention_weights['decoder_layer{}_block1'.format(i + 1)]  = block1
        attention_weights['decoder_layer{}_block2'.format(i + 1)] = block2
        # x.shape == (batch_size, target_seq_len, d_model)
        return x, attention_weights 

哇,这真是一大段代码。Transformer 模型的结构如此优雅。该模型的美丽之处在于,允许我们堆叠更多的编码器和解码器层,创建更强大的模型,正如最*的 GPT-3 所展示的那样。让我们将编码器和解码器结合起来,构建一个完整的 Transformer。

Transformer

Transformer 由编码器、解码器和最终的密集层组成,用于生成跨子词词汇的输出令牌分布:

class Transformer(tf.keras.Model):
    def __init__(self, num_layers, d_model, num_heads, dff,
                 target_vocab_size, pe_input, pe_target, rate=0.1,
                 use_pe=True):
        super(Transformer, self).__init__()
        self.encoder = VisualEncoder(num_layers, d_model, 
                                     num_heads, dff,
                                     pe_input, rate, use_pe)
        self.decoder = Decoder(num_layers, d_model, num_heads, 
                       dff, target_vocab_size, pe_target, rate)
        self.final_layer = tf.keras.layers.Dense(
                                       target_vocab_size)
    def call(self, inp, tar, training, enc_padding_mask,
             look_ahead_mask, dec_padding_mask):
        # (batch_size, inp_seq_len, d_model)
        enc_output = self.encoder(inp, training, enc_padding_mask)
        # dec_output.shape == (batch_size, tar_seq_len, d_model)
        dec_output, attention_weights = self.decoder(
                                tar, enc_output, training, 
                                look_ahead_mask, dec_padding_mask)
        # (batch_size, tar_seq_len, target_vocab_size)
        final_output = self.final_layer(dec_output)
        return final_output, attention_weights 

这就是完整 Transformer 代码的快速概览。理想情况下,TensorFlow 中的 Keras 将提供一个更高层次的 API 来定义 Transformer 模型,而不需要你编写代码。如果这对你来说有点复杂,那就专注于掩码和 VisualEncoder,因为它们是与标准 Transformer 架构的唯一偏离之处。

我们现在准备开始训练模型。我们将采用与上一章相似的方法,通过设置学习率衰减和检查点进行训练。

使用 VisualEncoder 训练 Transformer 模型

训练 Transformer 模型可能需要几个小时,因为我们希望训练大约 20 个 epoch。最好将训练代码放到一个文件中,这样可以从命令行运行。请注意,即使训练只进行 4 个 epoch,模型也能显示一些结果。训练代码位于caption-training.py文件中。总体来说,在开始训练之前需要执行以下步骤。首先,加载包含标题和图像名称的 CSV 文件,并附加包含提取图像特征文件的相应路径。还需要加载 Subword Encoder。创建一个tf.data.Dataset,包含编码后的标题和图像特征,便于批处理并将它们输入到模型中进行训练。为训练创建一个损失函数、一个带有学习率计划的优化器。使用自定义训练循环来训练 Transformer 模型。让我们详细了解这些步骤。

加载训练数据

以下代码加载我们在预处理步骤中生成的 CSV 文件:

prefix = './data/'
save_prefix = prefix + "features/"  # for storing prefixes
annot = prefix + 'data.csv'
inputs = pd.read_csv(annot, header=None, 
                      names=["caption", "image"])
print("Data file loaded") 

数据中的标题使用我们之前生成并保存在磁盘上的 Subword Encoder 进行分词:

cap_tokenizer = \
          tfds.features.text.SubwordTextEncoder.load_from_file(
                                                    "captions")
print(cap_tokenizer.encode(
                  "A man riding a wave on top of a surfboard.".lower())
)
print("Tokenizer hydrated")
# Max length of captions split by spaces
lens = inputs['caption'].map(lambda x: len(x.split()))
# Max length of captions after tokenization
# tfds demonstrated in earlier chapters
# This is a quick way if data fits in memory
lens = inputs['caption'].map(
                lambda x: len(cap_tokenizer.encode(x.lower()))
)
# We will set this as the max length of captions
# which cover 99% of the captions without truncation
max_len = int(lens.quantile(0.99) + 1)  # for special tokens 

最大的标题长度是为了适应 99%的标题长度而生成的。所有的标题都会被截断或填充到这个最大长度:

start = '<s>'
end = '</s>'
inputs['tokenized'] = inputs['caption'].map(
    lambda x: start + x.lower().strip() + end)
def tokenize_pad(x):
    x = cap_tokenizer.encode(x)
    if len(x) < max_len:
        x = x + [0] * int(max_len - len(x))
    return x[:max_len]
inputs['tokens'] = inputs.tokenized.map(lambda x: tokenize_pad(x)) 

图像特征被保存在磁盘上。当训练开始时,这些特征需要从磁盘读取并与编码后的标题一起输入。然后,包含图像特征的文件名被添加到数据集中:

# now to compute a column with the new name of the saved 
# image feature file
inputs['img_features'] = inputs['image'].map(lambda x:
                                             save_prefix +
                                             x.split('/')[-1][:-3]
                                             + 'npy') 

创建一个tf.data.Dataset,并设置一个映射函数,在枚举批次时读取图像特征:

captions = inputs.tokens.tolist()
img_names = inputs.img_features.tolist()
# Load the numpy file with extracted ResNet50 feature
def load_image_feature(img_name, cap):
    img_tensor = np.load(img_name.decode('utf-8'))
    return img_tensor, cap
dataset = tf.data.Dataset.from_tensor_slices((img_train, 
                                              cap_train))
# Use map to load the numpy files in parallel
dataset = dataset.map(lambda item1, item2: tf.numpy_function(
    load_image_feature, [item1, item2], [tf.float32, tf.int32]),
    num_parallel_calls=tf.data.experimental.AUTOTUNE) 

现在数据集已经准备好,我们可以实例化 Transformer 模型了。

实例化 Transformer 模型

我们将实例化一个较小的模型,具体来说是层数、注意力头数、嵌入维度和前馈单元的数量:

# Small Model
num_layers = 4
d_model = 128
dff = d_model * 4
num_heads = 8 

为了比较,BERT 基础模型包含以下参数:

# BERT Base Model
# num_layers = 12
# d_model = 768
# dff = d_model * 4
# num_heads = 12 

这些设置在文件中可用,但被注释掉了。使用这些设置会减慢训练速度,并需要大量的 GPU 内存。还需要设置其他一些参数,并实例化 Transformer:

target_vocab_size = cap_tokenizer.vocab_size  
# already includes start/end tokens
dropout_rate = 0.1
EPOCHS = 20  # should see results in 4-10 epochs also
transformer = vt.Transformer(num_layers, d_model, num_heads, dff,
                             target_vocab_size,
                             pe_input=49,  # 7x7 pixels
                             pe_target=target_vocab_size,
                             rate=dropout_rate,
                             use_pe=False
                             ) 

这个模型包含超过 400 万个可训练参数。它比我们之前看到的模型要小:

Model: "transformer"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
visual_encoder (VisualEncode multiple                  1055360   
_________________________________________________________________
decoder (Decoder)            multiple                  2108544   
_________________________________________________________________
dense_65 (Dense)             multiple                  1058445   
=================================================================
Total params: 4,222,349
Trainable params: 4,222,349
Non-trainable params: 0
_________________________________________________________________ 

然而,由于输入维度尚未提供,因此模型摘要不可用。一旦我们通过模型运行一个训练示例,摘要将可用。

为训练模型创建一个自定义学习率调度。自定义学习率调度会随着模型准确性的提高而逐渐降低学习率,从而提高准确性。这个过程被称为学习率衰减或学习率退火,在第五章《使用 RNN 和 GPT-2 生成文本》中有详细讨论。

自定义学习率调度

这个学习率调度与Attention Is All You Need 论文中提出的完全相同:

class CustomSchedule(tf.keras.optimizers.schedules.LearningRateSchedule):
    def __init__(self, d_model, warmup_steps=4000):
        super(CustomSchedule, self).__init__()
        self.d_model = d_model
        self.d_model = tf.cast(self.d_model, tf.float32)
        self.warmup_steps = warmup_steps
    def __call__(self, step):
        arg1 = tf.math.rsqrt(step)
        arg2 = step * (self.warmup_steps ** -1.5)
        return tf.math.rsqrt(self.d_model) * \
                tf.math.minimum(arg1, arg2)
learning_rate = CustomSchedule(d_model)
optimizer = tf.keras.optimizers.Adam(learning_rate, 
                                     beta_1=0.9, beta_2=0.98,
                                     epsilon=1e-9) 

以下图表显示了学习计划:

一个人的特写 描述自动生成

图 7.13:自定义学习率调度

当训练开始时,由于损失较高,因此使用较高的学习率。随着模型不断学习,损失开始下降,这时需要使用较低的学习率。采用上述学习率调度可以显著加快训练和收敛。我们还需要一个损失函数来进行优化。

损失和指标

损失函数基于类别交叉熵。这是一个常见的损失函数,我们在前几章中也使用过。除了损失函数外,还定义了准确度指标,以跟踪模型在训练集上的表现:

loss_object = tf.keras.losses.SparseCategoricalCrossentropy(
                              from_logits=True, reduction='none')
def loss_function(real, pred):
    mask = tf.math.logical_not(tf.math.equal(real, 0))
    loss_ = loss_object(real, pred)
    mask = tf.cast(mask, dtype=loss_.dtype)
    loss_ *= mask
    return tf.reduce_sum(loss_) / tf.reduce_sum(mask)
train_loss = tf.keras.metrics.Mean(name='train_loss')
train_accuracy = tf.keras.metrics.SparseCategoricalAccuracy(
                        name='train_accuracy') 

这个公式在前几章中也有使用。我们已经差不多可以开始训练了。在进入自定义训练函数之前,我们还需要完成两个步骤。我们需要设置检查点,以便在发生故障时保存进度,同时还需要为 Encoder 和 Decoder 屏蔽输入。

检查点和屏蔽

我们需要指定一个检查点目录,以便 TensorFlow 保存进度。这里我们将使用 CheckpointManager,它会自动管理检查点并存储有限数量的检查点。一个检查点可能非常大。对于小模型,五个检查点大约占用 243 MB 的空间。更大的模型会占用更多的空间:

checkpoint_path = "./checkpoints/train-small-model-40ep"
ckpt = tf.train.Checkpoint(transformer=transformer,
                           optimizer=optimizer)
ckpt_manager = tf.train.CheckpointManager(ckpt, checkpoint_path, 
                           max_to_keep=5)
# if a checkpoint exists, restore the latest checkpoint.
if ckpt_manager.latest_checkpoint:
    ckpt.restore(ckpt_manager.latest_checkpoint)
    print ('Latest checkpoint restored!!') 

接下来,必须定义一个方法来为输入图像和字幕创建屏蔽:

def create_masks(inp, tar):
    # Encoder padding mask - This should just be 1's
    # input shape should be (batch_size, 49, 2048)
    inp_seq = tf.ones([inp.shape[0], inp.shape[1]])  
    enc_padding_mask = vt.create_padding_mask(inp_seq)
    # Used in the 2nd attention block in the Decoder.
    # This padding mask is used to mask the encoder outputs.
    dec_padding_mask = vt.create_padding_mask(inp_seq)
    # Used in the 1st attention block in the Decoder.
    # It is used to pad and mask future tokens in the input 
    # received by the decoder.
    look_ahead_mask = vt.create_look_ahead_mask(tf.shape(tar)[1])
    dec_target_padding_mask = vt.create_padding_mask(tar)
    combined_mask = tf.maximum(dec_target_padding_mask, 
                                  look_ahead_mask)
    return enc_padding_mask, combined_mask, dec_padding_mask 

输入始终是固定长度,因此输入序列被设置为全 1。只有 Decoder 使用的字幕被屏蔽。Decoder 有两种类型的屏蔽。第一种是填充屏蔽。由于字幕被设置为最大长度,以处理 99%的字幕,大约是 22 个标记,任何少于这个标记数的字幕都会在末尾添加填充。填充屏蔽有助于将字幕标记与填充标记分开。第二种是前瞻屏蔽。它防止 Decoder 看到未来的标记或尚未生成的标记。现在,我们准备好开始训练模型。

自定义训练

与摘要模型类似,训练时将使用教师强制(teacher forcing)。因此,将使用一个定制的训练函数。首先,我们必须定义一个函数来对一批数据进行训练:

@tf.function
def train_step(inp, tar):
    tar_inp = tar[:, :-1]
    tar_real = tar[:, 1:]
    enc_padding_mask, combined_mask, dec_padding_mask = create_masks(inp, tar_inp)
    with tf.GradientTape() as tape:
        predictions, _ = transformer(inp, tar_inp,
                                     True,
                                     enc_padding_mask,
                                     combined_mask,
                                     dec_padding_mask)
        loss = loss_function(tar_real, predictions)
    gradients = tape.gradient(loss, 
                                transformer.trainable_variables)
    optimizer.apply_gradients(zip(gradients, 
                                   transformer.trainable_variables))
    train_loss(loss)
    train_accuracy(tar_real, predictions) 

该方法与摘要训练代码非常相似。现在我们需要做的就是定义训练的轮数(epochs)和批次大小(batch size),然后开始训练:

# setup training parameters
BUFFER_SIZE = 1000
BATCH_SIZE = 64  # can +/- depending on GPU capacity
# Shuffle and batch
dataset = dataset.shuffle(BUFFER_SIZE).batch(BATCH_SIZE)
dataset = dataset.prefetch(buffer_size=tf.data.experimental.AUTOTUNE)
# Begin Training
for epoch in range(EPOCHS):
    start_tm = time.time()
    train_loss.reset_states()
    train_accuracy.reset_states()
    # inp -> images, tar -> caption
    for (batch, (inp, tar)) in enumerate(dataset):
        train_step(inp, tar)
        if batch % 100 == 0:
            ts = datetime.datetime.now().strftime(
                                      "%d-%b-%Y (%H:%M:%S)")
            print('[{}] Epoch {} Batch {} Loss {:.6f} Accuracy'+\ 
                   '{:.6f}'.format(ts, epoch + 1, batch,
                                   train_loss.result(),
                                   train_accuracy.result()))
    if (epoch + 1) % 2 == 0:
        ckpt_save_path = ckpt_manager.save()
        print('Saving checkpoint for epoch {} at {}'.format(
                               epoch + 1,
                               ckpt_save_path))
    print('Epoch {} Loss {:.6f} Accuracy {:.6f}'.format(epoch + 1,
                                       train_loss.result(),
                                       train_accuracy.result()))
    print('Time taken for 1 epoch: {} secs\n'.format(
                                     time.time() - start_tm)) 

训练可以从命令行启动:

(tf24nlp) $ python caption-training.py 

这次训练可能需要一些时间。一个训练周期在我启用 GPU 的机器上大约需要 11 分钟。如果与摘要模型对比,这个模型的训练速度非常快。与包含 1300 万个参数的摘要模型相比,它要小得多,训练也非常快。这个速度提升归功于缺乏递归结构。

最先进的摘要模型使用 Transformer 架构,并结合子词编码。考虑到你已经掌握了 Transformer 的所有构件,一个很好的练习来测试你的理解就是编辑 VisualEncoder 来处理文本,并将摘要模型重建为 Transformer。这样你就能体验到这些加速和精度的提升。

更长的训练时间能让模型学习得更好。然而,这个模型在仅经过 5-10 个训练周期后就能给出合理的结果。训练完成后,我们可以尝试将模型应用到一些图像上。

生成字幕

首先,你需要祝贺自己!你已经通过了一个快速实现 Transformer 的过程。我相信你一定注意到前几章中使用过的一些常见构建块。由于 Transformer 模型非常复杂,我们把它放到这一章,探讨像巴赫达努(Bahdanau)注意力机制、定制层、定制学习率计划、使用教师强制(teacher forcing)进行的定制训练以及检查点(checkpoint)等技术,这样我们就可以快速覆盖大量内容。在你尝试解决 NLP 问题时,应该把所有这些构建块视为你工具包中的重要组成部分。

不再多说,让我们尝试为一些图像生成字幕。我们将再次使用 Jupyter notebook 进行推理,以便快速尝试不同的图像。所有推理代码都在image-captioning-inference.ipynb文件中。

推理代码需要加载子词编码器,设置遮掩(masking),实例化一个 ResNet50 模型来从测试图像中提取特征,并一遍遍地生成字幕,直到序列结束或达到最大序列长度。让我们一一走过这些步骤。

一旦我们完成了适当的导入并可选地初始化了 GPU,就可以加载在数据预处理时保存的子词编码器(Subword Encoder):

cap_tokenizer = tfds.features.text.SubwordTextEncoder.load_from_file("captions") 

现在我们必须实例化 Transformer 模型。这是一个重要步骤,确保参数与检查点中的参数相同:

# Small Model
num_layers = 4
d_model = 128
dff = d_model * 4
num_heads = 8
target_vocab_size = cap_tokenizer.vocab_size  # already includes 
                                              # start/end tokens
dropout_rate = 0\. # immaterial during inference
transformer = vt.Transformer(num_layers, d_model, num_heads, dff,
                             target_vocab_size,
                             pe_input=49,  # 7x7 pixels
                             pe_target=target_vocab_size,
                             rate=dropout_rate
                             ) 

从检查点恢复模型时需要优化器,即使我们并没有训练模型。因此,我们将重用训练代码中的自定义调度器。由于该代码之前已经提供,这里省略了它。对于检查点,我使用了一个训练了 40 个周期的模型,但在编码器中没有使用位置编码:

checkpoint_path = "./checkpoints/train-small-model-nope-40ep"
ckpt = tf.train.Checkpoint(transformer=transformer,
                           optimizer=optimizer)
ckpt_manager = tf.train.CheckpointManager(ckpt, checkpoint_path, 
                                            max_to_keep=5)
# if a checkpoint exists, restore the latest checkpoint.
if ckpt_manager.latest_checkpoint:
    ckpt.restore(ckpt_manager.latest_checkpoint)
    print ('Latest checkpoint restored!!') 

最后,我们必须为生成的字幕设置遮罩功能。请注意,前瞻遮罩在推理过程中并没有真正的帮助,因为未来的标记尚未生成:

# Helper function for creating masks
def create_masks(inp, tar):
    # Encoder padding mask - This should just be 1's
    # input shape should be (batch_size, 49, 2048)
    inp_seq = tf.ones([inp.shape[0], inp.shape[1]])  
    enc_padding_mask = vt.create_padding_mask(inp_seq)
    # Used in the 2nd attention block in the Decoder.
    # This padding mask is used to mask the encoder outputs.
    dec_padding_mask = vt.create_padding_mask(inp_seq)
    # Used in the 1st attention block in the Decoder.
    # It is used to pad and mask future tokens in the input received by
    # the decoder.
    look_ahead_mask = vt.create_look_ahead_mask(tf.shape(tar)[1])
    dec_target_padding_mask = vt.create_padding_mask(tar)
    combined_mask = tf.maximum(dec_target_padding_mask, 
                                 look_ahead_mask)
    return enc_padding_mask, combined_mask, dec_padding_mask 

推理的主要代码在evaluate()函数中。此方法将 ResNet50 生成的图像特征作为输入,并用起始标记来初始化输出字幕序列。然后,它进入循环,每次生成一个标记,同时更新遮罩,直到遇到序列结束标记或达到字幕的最大长度:

def evaluate(inp_img, max_len=21):
    start_token = cap_tokenizer.encode("<s>")[0]
    end_token = cap_tokenizer.encode("</s>")[0]

    encoder_input = inp_img # batch of 1

    # start token for caption
    decoder_input = [start_token]
    output = tf.expand_dims(decoder_input, 0)
    for i in range(max_len):
        enc_padding_mask, combined_mask, dec_padding_mask = \
                create_masks(encoder_input, output)

        # predictions.shape == (batch_size, seq_len, vocab_size)
        predictions, attention_weights = transformer(
                                               encoder_input, 
                                               output,
                                               False,
                                               enc_padding_mask,
                                               combined_mask,
                                               dec_padding_mask)
        # select the last word from the seq_len dimension
        predictions = predictions[: ,-1:, :]  
        predicted_id = tf.cast(tf.argmax(predictions, axis=-1), 
                                  tf.int32)

        # return the result if predicted_id is equal to end token
        if predicted_id == end_token:
            return tf.squeeze(output, axis=0), attention_weights

        # concatenate the predicted_id to the output which is 
        # given to the decoder  as its input.
        output = tf.concat([output, predicted_id], axis=-1)
    return tf.squeeze(output, axis=0), attention_weights 

使用一个包装方法来调用评估方法并输出字幕:

def caption(image):
    end_token = cap_tokenizer.encode("</s>")[0]
    result, attention_weights = evaluate(image)

    predicted_sentence = cap_tokenizer.decode([i for i in result 
                                              if i > end_token])
    print('Predicted Caption: {}'.format(predicted_sentence)) 

现在唯一剩下的就是实例化一个 ResNet50 模型,以便从图像文件中动态提取特征:

rs50 = tf.keras.applications.ResNet50(
    include_top=False,
    weights="imagenet",  # no pooling
    input_shape=(224, 224, 3)
)
new_input = rs50.input
hidden_layer = rs50.layers[-1].output
features_extract = tf.keras.Model(new_input, hidden_layer) 

终于到了关键时刻!让我们在一张图像上尝试一下模型。我们将加载图像,对其进行 ResNet50 预处理,并从中提取特征:

# from keras
image = load_img("./beach-surf.jpg", target_size=(224, 224)) 
image = img_to_array(image)
image = np.expand_dims(image, axis=0)  # batch of one
image = preprocess_input(image)  # from resnet
eval_img = features_extract.predict(image)
caption(eval_img) 

以下是示例图像及其字幕:

一个人正在海洋中的冲浪板上骑着波浪  自动生成的描述

图 7.14:生成的字幕 - 一名男子在波浪上骑着冲浪板

这看起来是给定图像的一个精彩字幕!然而,模型的整体准确度只有 30%左右。模型还有很大的改进空间。下一节将讨论图像字幕生成的最先进技术,并提出一些你可以尝试和玩弄的更简单的思路。

请注意,你可能会看到略微不同的结果。这本书的审阅者在运行这段代码时得到的结果是一个穿黑衬衫的男人正在骑冲浪板。这是预期的结果,因为概率上的微小差异以及模型在损失曲面中停止训练的精确位置并不完全相同。我们在这里是操作于概率领域,因此可能会有一些微小的差异。你可能也在前几章的文本生成和摘要代码中经历过类似的差异。

以下图像展示了更多图像及其字幕的示例。该笔记本包含了几个不错的示例,也有一些糟糕的生成标签示例:

一张包含照片、不同、各种、群体的图片  自动生成的描述

图 7.15:图像及其生成的字幕示例

这些图像都不在训练集中。从上到下,字幕质量下降。我们的模型理解特写、蛋糕、人群、沙滩、街道和行李等等。然而,最后两个示例令人担忧。它们暗示模型中存在某种偏见。在这两张底部的图像中,模型误解了性别。

这些图像是有意选择的,展示了一位穿着商务套装的女性和打篮球的女性。在这两种情况下,模型在字幕中建议的都是男性。当模型尝试使用女子网球运动员的图像时,它猜对了性别,但在女子足球比赛的图像中却改变了性别。模型中的偏见在图像字幕等情况下是立即显现的。事实上,2019 年在发现它如何分类和标记图像中存在偏见后,ImageNet 数据库中已移除了超过 60 万张图像(bit.ly/3qk4FgN)。ResNet50 是在 ImageNet 上预训练的。然而,在其他模型中,偏见可能更难以检测。建立公*的深度学习模型和减少模型偏见是机器学习社区的活跃研究领域。

你可能已经注意到,我们跳过了在评估集和测试集上运行模型的步骤。这是为了简洁起见,也因为这些技术之前已经涵盖过了。

关于评估字幕质量的度量标准的简短说明。在前几章节中我们看到了 ROUGE 度量标准。在图像字幕中,ROUGE-L 仍然适用。您可以将字幕的心理模型视为图像的摘要,而不是文本摘要中段落的摘要。有多种表达摘要的方式,而 ROUGE-L 试图捕捉意图。还有两个常报告的度量标准:

  • BLEU:这代表双语评估助手,是机器翻译中最流行的度量标准。我们也可以把图像字幕问题视为机器翻译问题。它依赖于 n-gram 来计算预测文本与多个参考文本的重叠,并将结果合并为一个分数。

  • CIDEr:这代表基于一致性的图像描述评估,并在 2015 年的同名论文中提出。它试图处理自动评估的困难,当多个字幕可能都合理时,通过结合 TF-IDF 和 n-gram 来比较模型生成的字幕与多个人类注释者的字幕,并根据共识进行评分。

在本章结束之前,让我们花点时间讨论提升性能和最先进模型的方法。

提升性能和最先进模型

在讨论最新的模型之前,让我们先讨论一些你可以尝试的简单实验来提高性能。回想一下我们在编码器中对输入位置编码的讨论。添加或移除位置编码会有助于或妨碍性能。在上一章中,我们实现了用于生成摘要的束搜索算法。你可以调整束搜索代码,并在结果中看到束搜索的改善。另一个探索方向是 ResNet50。我们使用了一个预训练的网络,但没有进一步微调。也可以构建一个架构,其中 ResNet 是架构的一部分,而不是一个预处理步骤。图像文件被加载,并且特征从 ResNet50 中提取作为视觉编码器的一部分。ResNet50 的层可以从一开始就进行训练,或者仅在最后几次迭代中训练。这一想法在resnet-finetuning.py文件中实现,你可以尝试。另一种思路是使用与 ResNet50 不同的物体检测模型,或者使用来自不同层的输出。你可以尝试使用更复杂的 ResNet 版本,如 ResNet152,或使用来自 Facebook 的 Detectron 等其他物体检测模型。由于我们的代码非常模块化,因此使用不同的模型应该是非常简单的。

当你使用不同的模型来提取图像特征时,关键是确保张量维度能够在编码器中正确流动。解码器不应需要任何更改。根据模型的复杂性,你可以选择预处理并存储图像特征,或者实时计算它们。

回顾一下,我们直接使用了图像中的像素。这是基于最*在 CVPR 上发表的一篇名为Pixel-BERT的论文。大多数模型使用从图像中提取的区域提议,而不是直接使用像素。图像中的物体检测涉及在图像中的物体周围绘制边界。另一种执行相同任务的方法是将每个像素分类为物体或背景。这些区域提议可以以图像中的边界框形式存在。最先进的模型将边界框或区域提议作为输入。

图像描述的第二大提升来自于预训练。回想一下,BERT 和 GPT 都是在特定的预训练目标下进行预训练的。模型的区别在于,是否仅对编码器(Encoder)进行预训练,或者编码器和解码器(Decoder)都进行了预训练。一种常见的预训练目标是 BERT 的掩码语言模型(MLM)任务版本。回想一下,BERT 的输入结构为[CLS] I1 I2 … In [SEP] J1 J2 … Jk [SEP],其中输入序列中的部分标记被遮掩。这个过程被适用于图像描述,其中输入中的图像特征和描述标记被拼接在一起。描述标记被像 BERT 模型中的遮掩一样进行遮掩,预训练目标是让模型预测被遮掩的标记。预训练后,CLS 标记的输出可以用于分类,或者送入解码器生成描述。需要小心的是,不能在相同的数据集上进行预训练,例如评估时使用的数据集。设置的一个示例是使用 Visual Genome 和 Flickr30k 数据集进行预训练,并使用 COCO 进行微调。

图像描述是一个活跃的研究领域。关于多模态网络的研究才刚刚起步。现在,让我们回顾一下本章所学的内容。

总结

在深度学习的领域中,已经开发出特定的架构来处理特定的模态。卷积神经网络CNNs)在处理图像方面非常有效,是计算机视觉(CV)任务的标准架构。然而,研究领域正在向多模态网络的方向发展,这些网络能够处理多种类型的输入,如声音、图像、文本等,并且能够像人类一样进行认知。在回顾多模态网络后,我们将重点深入研究视觉和语言任务。这个领域中存在许多问题,包括图像描述、视觉问答、视觉-常识推理(VCR)和文本生成图像等。

基于我们在前几章中学习的 seq2seq 架构、自定义 TensorFlow 层和模型、自定义学习计划以及自定义训练循环,我们从零开始实现了一个 Transformer 模型。Transformer 模型是目前写作时的最先进技术。我们快速回顾了 CNN 的基本概念,以帮助理解图像相关部分。我们成功构建了一个模型,虽然它可能无法为一张图片生成千言万语,但它绝对能够生成一条人类可读的描述。该模型的表现仍需改进,我们讨论了若干可能性,以便进行改进,包括最新的技术。

很明显,当深度模型包含大量数据时,它们的表现非常出色。BERT 和 GPT 模型已经展示了在海量数据上进行预训练的价值。对于预训练或微调,获得高质量的标注数据仍然非常困难。在自然语言处理领域,我们有大量的文本数据,但标注数据却远远不够。下一章将重点介绍弱监督学习,构建能够为预训练甚至微调任务标注数据的分类模型。

第八章:使用 Snorkel 进行弱监督学习分类

像 BERT 和 GPT 这样的模型利用大量的未标注数据和无监督训练目标(例如 BERT 的掩码语言模型MLM)或 GPT 的下一个单词预测模型)来学习文本的基本结构。少量任务特定数据用于通过迁移学习微调预训练模型。这些模型通常非常庞大,拥有数亿个参数,需要庞大的数据集进行预训练,并且需要大量计算能力进行训练和预训练。需要注意的是,解决的关键问题是缺乏足够的训练数据。如果有足够的领域特定训练数据,BERT 类预训练模型的收益就不会那么大。在某些领域(如医学),任务特定数据中使用的词汇对于该领域是典型的。适量增加训练数据可以大大提高模型的质量。然而,手工标注数据是一项繁琐、资源密集且不可扩展的任务,尤其是对于深度学习所需的大量数据而言。

本章讨论了一种基于弱监督概念的替代方法。通过使用 Snorkel 库,我们在几小时内标注了数万个记录,并超越了在第三章中使用 BERT 开发的模型的准确性,命名实体识别(NER)与 BiLSTM、CRF 和维特比解码。本章内容包括:

  • 弱监督学习概述

  • 生成模型和判别模型之间差异的概述

  • 使用手工特征构建基准模型以标注数据

  • Snorkel 库基础

  • 使用 Snorkel 标签函数大规模增强训练数据

  • 使用噪声机器标注数据训练模型

理解弱监督学习的概念至关重要,因此我们先从这个概念开始。

弱监督学习

*年来,深度学习模型取得了令人难以置信的成果。深度学习架构消除了特征工程的需求,只要有足够的训练数据。然而,深度学习模型学习数据的基本结构需要大量数据。一方面,深度学习减少了手工特征制作所需的人工工作,但另一方面,它大大增加了特定任务所需的标注数据量。在大多数领域,收集大量高质量标注数据是一项昂贵且资源密集的任务。

这个问题可以通过多种方式解决。在前面的章节中,我们已经看到了使用迁移学习在大数据集上训练模型,然后再针对特定任务微调模型。图 8.1 展示了这种方法以及其他获取标签的方法:

A picture containing clock  Description automatically generated

图 8.1:获取更多标注数据的选项

手动标记数据是一种常见的方法。理想情况下,我们有足够的时间和金钱来雇佣专业学科专家(SMEs)手动标记每一条数据,这是不切实际的。考虑标记肿瘤检测数据集并雇佣肿瘤学家来进行标记任务。对于肿瘤学家来说,标记数据可能比治疗肿瘤患者的优先级低得多。在以前的公司,我们组织了披萨派对,我们会为人们提供午餐来标记数据。一个人可以在一个小时内标记大约 100 条记录。每月为 10 人提供午餐一年,结果是 12,000 条标记记录!这种方案对于模型的持续维护非常有用,我们会对模型无法处理或置信度较低的记录进行采样。因此,我们采用了主动学习,确定标记数据对分类器性能影响最大的记录。

另一个选择是雇佣标记人员,他们不是专家,但更为丰富且更便宜。这是亚马逊机械土耳其服务采取的方法。有大量公司提供标记服务。由于标记者不是专家,同一条记录可能会由多人标记,并且会使用类似多数表决的机制来决定记录的最终标签。通过一名标记者标记一条记录的收费可能会因为关联标签所需的步骤复杂性而从几分钱到几美元不等。这种过程的输出是一组具有高覆盖率的嘈杂标签,只要您的预算允许。我们仍需找出获取的标签质量,以确定这些标签如何在最终模型中使用。

弱监督试图以不同的方式解决问题。假如,使用启发式方法,一位专业学科专家(SME)可以在几秒钟内手工标记成千上万条记录?我们将使用 IMDb 电影评论数据集,并尝试预测评论的情感。我们在第四章中使用了 IMDb 数据集,BERT 迁移学习,我们探索了迁移学习。使用相同的示例展示一个替代的技术来展示迁移学习是合适的。

弱监督技术不必用作迁移学习的替代品。弱监督技术有助于创建更大的领域特定标记数据集。在没有迁移学习的情况下,一个更大的标记数据集即使来自弱监督的嘈杂标签,也会提高模型性能。然而,如果同时使用迁移学习和弱监督,模型性能的提升将更为显著。

一个简单的启发式函数示例用于将评论标记为具有积极情感的伪代码如下所示:

if movie.review has "amazing acting" in it:
then sentiment is positive 

虽然这对于我们的用例看起来是一个微不足道的例子,但你会惊讶于它的有效性。在更复杂的设置中,一位肿瘤学家可以提供一些这些启发式规则,并定义一些标注函数来标注一些记录。这些函数可能会相互冲突或重叠,类似于众包标签。获取标签的另一种方法是通过远程监督。可以使用外部知识库(如 Wikipedia)通过启发式方法为数据记录标注。在命名实体识别NER)的用例中,使用地名表来将实体匹配到已知实体列表,如第二章《使用 BiLSTM 理解自然语言中的情感》所讨论。在实体之间的关系抽取中,例如员工属于配偶是,可以从实体的 Wikipedia 页面中提取关系,进而标注数据记录。还有其他获取这些标签的方法,例如使用对生成数据的底层分布的深刻理解。

对于给定的数据集,标签可以来自多个来源。每个众包标签员都是一个来源。每个启发式函数(如上面展示的“惊人的演技”函数)也是一个来源。弱监督的核心问题是如何将这些多个来源结合起来,生成足够质量的标签供最终分类器使用。模型的关键点将在下一节描述。

本章中提到的领域特定模型被称为分类器,因为我们所取的示例是电影评论情感的二分类。然而,生成的标签可以用于多种领域特定的模型。

弱监督与标注函数的内部工作原理

少数启发式标注函数覆盖率低、准确度不完美,但仍能帮助提高判别模型准确度的想法听起来很棒。本节提供了一个高层次的概述,介绍了它是如何工作的,然后我们将在 IMDb 情感分析数据集上实践这一过程。

我们假设这是一个二分类问题进行说明,尽管该方案适用于任意数量的标签。二分类的标签集为{NEG, POS}。我们有一组未标记的数据点,X,包含m个样本。

请注意,我们无法访问这些数据点的实际标签,但我们用Y表示生成的标签。假设我们有n个标注函数LF[1]到LF[n],每个函数生成一个标签。然而,我们为弱监督添加了另一个标签——一个弃权标签。每个标注函数都有选择是否应用标签或放弃标注的能力。这是弱监督方法的一个关键方面。因此,标注函数生成的标签集扩展为{NEG, ABSTAIN, POS}。

在这种设置下,目标是训练一个生成模型,该模型建模两个方面:

  • 给定数据点的标注函数弃权的概率

  • 给定标注函数正确分配标签到数据点的概率

通过在所有数据点上应用所有标注函数,我们生成一个m × n的矩阵,表示数据点及其标签。由启发式标注函数LF[j]对数据点X[i]生成的标签可以表示为:

生成模型试图通过标注函数之间的共识与分歧来学习参数。

生成模型与判别模型

如果我们有一组数据X,以及与数据对应的标签Y,我们可以说,判别模型试图捕捉条件概率p(Y | X)。生成模型则捕捉联合概率 p(X, Y)。顾名思义,生成模型可以生成新的数据点。我们在第五章中看到生成模型的例子,通过 RNN 和 GPT-2 生成文本*,在那里我们生成了新闻头条。GANs生成对抗网络)和自编码器是著名的生成模型。判别模型则对给定数据集中的数据点进行标注。它通过在特征空间中划分一个*面,将数据点分成不同的类别。像 IMDb 情感评论预测模型这样的分类器通常是判别模型。

如可以想象,生成模型在学习数据的整个潜在结构方面面临着更具挑战性的任务。

生成模型的参数权重,w,可以通过以下方式估计:

注意,观察到的标签的对数边际似然性会因预测标签Y而因式分解。因此,该生成模型是以无监督方式工作的。一旦生成模型的参数被计算出来,我们就可以预测数据点的标签,表示为:

其中,Y[i]表示基于标注函数的标签,表示生成模型的预测标签。这些预测标签可以被传递给下游的判别模型进行分类。

这些概念在 Snorkel 库中得到了实现。Snorkel 库的作者是引入数据编程方法的关键贡献者,该方法在 2016 年神经信息处理系统会议(Neural Information Process Systems conference)上以同名论文形式发表。Snorkel 库在 2019 年由 Ratner 等人在题为Snorkel: rapid training data creation with weak supervision的论文中正式介绍。苹果和谷歌分别发布了使用 Snorkel 库的论文,分别是关于OvertonSnorkel Drybell的论文。这些论文可以提供关于使用弱监督创建训练数据的数学证明的深入讨论。

尽管底层原理可能很复杂,但在实践中,使用 Snorkel 进行数据标注并不难。让我们通过准备数据集开始吧。

使用弱监督标签来改进 IMDb 情感分析

对 IMDb 网站上的电影评论进行情感分析是分类类型自然语言处理NLP)模型的标准任务。我们在第四章中使用了这些数据来演示使用 GloVe 和 VERT 嵌入进行迁移学习。IMDb 数据集包含 25,000 个训练示例和 25,000 个测试示例。数据集还包括 50,000 条未标记的评论。在之前的尝试中,我们忽略了这些无监督的数据点。增加更多的训练数据将提高模型的准确性。然而,手动标注将是一个耗时且昂贵的过程。我们将使用 Snorkel 驱动的标注功能,看看是否能够提高测试集上的预测准确性。

预处理 IMDb 数据集

之前,我们使用了tensorflow_datasets包来下载和管理数据集。然而,为了实现标签功能的编写,我们需要对数据进行更低级别的访问。因此,第一步是从网络上下载数据集。

本章的代码分为两个文件。snorkel-labeling.ipynb文件包含用于下载数据和使用 Snorkel 生成标签的代码。第二个文件imdb-with-snorkel-labels.ipynb包含训练有无额外标注数据的模型的代码。如果运行代码,最好先运行snorkel-labeling.ipynb文件中的所有代码,以确保生成所有标注数据文件。

数据集包含在一个压缩档案中,可以通过如下方式下载并解压,如snorkel-labeling.ipynb所示:

(tf24nlp) $ wget https://ai.stanford.edu/~amaas/data/sentiment/aclImdb_v1.tar.gz
(tf24nlp) $ tar xvzf aclImdb_v1.tar.gz 

这将解压档案到aclImdb目录中。训练数据和无监督数据位于train/子目录中,测试数据位于test/子目录中。还有一些额外的文件,但它们可以忽略。下面的图 8.2展示了目录结构:

图 8.2:IMDb 数据的目录结构

评论以单独的文本文件形式存储在叶目录中。每个文件的命名格式为<review_id>_<rating>.txt。评论标识符从 0 到 24999 按顺序编号,用于训练和测试示例。对于无监督数据,最高评论编号为 49999\。

评分是一个介于 0 到 9 之间的数字,仅在测试和训练数据中有意义。这个数字反映了对某个评论给出的实际评分。pos/子目录中的所有评论情感为正面,neg/子目录中的评论情感为负面。评分为 0 到 4 被视为负面,而评分为 5 到 9(包括 9)则视为正面。在这个特定的示例中,我们不使用实际评分,只考虑整体情感。

我们将数据加载到pandas DataFrame 中,方便处理。定义了一个便捷函数,将子目录中的评论加载到 DataFrame 中:

def load_reviews(path, columns=["filename", 'review']):
    assert len(columns) == 2
    l = list()
    for filename in glob.glob(path):
        # print(filename)
        with open(filename, 'r') as f:
            review = f.read()
            l.append((filename, review))
    return pd.DataFrame(l, columns=columns) 

上述方法将数据加载到两列中——一列为文件名,另一列为文件的文本。使用这种方法,加载了无监督数据集:

unsup_df = load_reviews("./aclImdb/train/unsup/*.txt")
unsup_df.describe() 
filename 

|

review 

|

--- --- ---

|

count 

|

50000 

|

50000 

|

|

unique 

|

50000 

|

49507 

|

|

top 

|

./aclImdb/train/unsup/24211_0.txt 

|

Am not from America, I usually watch this show... 

|

|

freq 

|

1 

|

5 

|

训练和测试数据集使用略有不同的方法:

def load_labelled_data(path, neg='/neg/', 
                       pos='/pos/', shuffle=True):
    neg_df = load_reviews(path + neg + "*.txt")
    pos_df = load_reviews(path + pos + "*.txt")
    neg_df['sentiment'] = 0
    pos_df['sentiment'] = 1
    df = pd.concat([neg_df, pos_df], axis=0)
    if shuffle:
        df = df.sample(frac=1, random_state=42)
    return df 

该方法返回三列——文件名、评论文本和情感标签。如果情感为负,则情感标签为 0;如果情感为正,则情感标签为 1,这由评论所在的目录决定。

现在可以像这样加载训练数据集:

train_df = load_labelled_data("./aclImdb/train/")
train_df.head() 
filename 

|

review 

|

sentiment 

|

--- --- --- ---

|

6868 

|

./aclImdb/train//neg/6326_4.txt 

|

If you're in the mood for some dopey light ent... 

|

0 

|

|

11516 

|

./aclImdb/train//pos/11177_8.txt 

|

*****Spoilers herein*****<br /><br />What real... 

|

1 

|

|

9668 

|

./aclImdb/train//neg/2172_2.txt 

|

Bottom of the barrel, unimaginative, and pract... 

|

0 

|

|

1140 

|

./aclImdb/train//pos/2065_7.txt 

|

Fearful Symmetry is a pleasant episode with a ... 

|

1 

|

|

1518 

|

./aclImdb/train//pos/7147_10.txt 

|

I found the storyline in this movie to be very... 

|

1 

|

虽然我们没有使用原始分数进行情感分析,但这是一个很好的练习,你可以尝试预测分数而不是情感。为了帮助处理原始文件中的分数,可以使用以下代码,该代码从文件名中提取分数:

def fn_to_score(f):
    scr = f.split("/")[-1]  # get file name
    scr = scr.split(".")[0] # remove extension
    scr = int (scr.split("_")[-1]) #the score
    return scr
train_df['score'] = train_df.filename.apply(fn_to_score) 

这会向 DataFrame 添加一个新的分数列,可以作为起始点使用。

测试数据可以通过传递不同的起始数据目录,使用相同的便捷函数加载。

test_df = load_labelled_data("./aclImdb/test/") 

一旦评论加载完成,下一步是创建一个分词器。

学习子词分词器

可以使用tensorflow_datasets包学习子词分词器。请注意,我们希望在学习此分词器时传递所有训练数据和无监督评论。

text = unsup_df.review.to_list() + train_df.review.to_list() 

这一步创建了一个包含 75,000 项的列表。如果检查评论文本,可以发现评论中包含一些 HTML 标签,因为这些评论是从 IMDb 网站抓取的。我们使用 Beautiful Soup 包来清理这些标签。

txt = [ BeautifulSoup(x).text for x in text ] 

然后,我们学习了包含 8,266 个条目的词汇表。

encoder = tfds.features.text.SubwordTextEncoder.\
                build_from_corpus(txt, target_vocab_size=2**13)
encoder.save_to_file("imdb") 

该编码器已保存到磁盘。学习词汇表可能是一个耗时的任务,并且只需要做一次。将其保存到磁盘可以在随后的代码运行中节省时间。

提供了一个预训练的子词编码器。它可以在与本章对应的 GitHub 文件夹中找到,并命名为imdb.subwords,如果你想跳过这些步骤,可以直接使用它。

在我们使用 Snorkel 标注的数据构建模型之前,先定义一个基准模型,以便在添加弱监督标签前后,比较模型的性能。

一个 BiLSTM 基准模型

为了理解额外标注数据对模型性能的影响,我们需要一个比较的基准。因此,我们设定一个之前见过的 BiLSTM 模型作为基准。有几个数据处理步骤,比如分词、向量化和填充/截断数据的长度。由于这些代码在第 3 和第四章中已经出现过,因此为了完整性,在此处进行了重复,并附上简明的描述。

Snorkel 在训练数据大小是原始数据的 10 倍到 50 倍时效果最佳。IMDb 提供了 50,000 条未标注的示例。如果所有这些都被标注,那么训练数据将是原始数据的 3 倍,这不足以展示 Snorkel 的价值。因此,我们通过将训练数据限制为仅 2,000 条记录,模拟了大约 18 倍的比例。其余的训练记录被视为未标注数据,Snorkel 用来提供噪声标签。为了防止标签泄露,我们将训练数据进行拆分,并存储为两个独立的数据框。拆分的代码可以在 snorkel-labeling.ipynb 笔记本中找到。用于生成拆分的代码片段如下所示:

from sklearn.model_selection import train_test_split
# Randomly split training into 2k / 23k sets
train_2k, train_23k = train_test_split(train_df, test_size=23000, 
                                      random_state=42, 
                                      stratify=train_df.sentiment)
train_2k.to_pickle("train_2k.df") 

使用分层拆分方法以确保正负标签的样本数量相等。一个包含 2,000 条记录的数据框被保存,并用于训练基准模型。请注意,这可能看起来像是一个人为构造的示例,但请记住,文本数据的关键特点是数据量庞大;然而,标签则稀缺。通常,标注数据的主要障碍是所需的标注工作量。在我们了解如何标注大量数据之前,先完成基准模型的训练以作比较。

分词和向量化数据

我们对训练集中的所有评论进行分词,并将其截断/填充为最多 150 个词元。评论通过 Beautiful Soup 处理,去除任何 HTML 标记。所有与此部分相关的代码都可以在 imdb-with-snorkel-labels.ipynb 文件中的 Training Data Vectorization 部分找到。这里仅为简便起见展示了部分代码:

# we need a sample of 2000 reviews for training
num_recs = 2000
train_small = pd.read_pickle("train_2k.df")
# we dont need the snorkel column
train_small = train_small.drop(columns=['snorkel'])
# remove markup
cleaned_reviews = train_small.review.apply(lambda x: BeautifulSoup(x).text)
# convert pandas DF in to tf.Dataset
train = tf.data.Dataset.from_tensor_slices(
                             (cleaned_reviews.values,
                             train_small.sentiment.values)) 

分词和向量化通过辅助函数进行,并应用于整个数据集:

# transformation functions to be used with the dataset
from tensorflow.keras. pre-processing import sequence
def encode_pad_transform(sample):
    encoded = imdb_encoder.encode(sample.numpy())
    pad = sequence.pad_sequences([encoded], padding='post', maxlen=150)
    return np.array(pad[0], dtype=np.int64)  
def encode_tf_fn(sample, label):
    encoded = tf.py_function(encode_pad_transform, 
                                       inp=[sample], 
                                       Tout=(tf.int64))
    encoded.set_shape([None])
    label.set_shape([])
    return encoded, label
encoded_train = train.map(encode_tf_fn,
                 num_parallel_calls=tf.data.experimental.AUTOTUNE) 

测试数据也以类似的方式进行处理:

# remove markup
cleaned_reviews = test_df.review.apply(
lambda x: BeautifulSoup(x).text)
# convert pandas DF in to tf.Dataset
test = tf.data.Dataset.from_tensor_slices((cleaned_reviews.values, 
                                        test_df.sentiment.values))
encoded_test = test.map(encode_tf_fn,
                 num_parallel_calls=tf.data.experimental.AUTOTUNE) 

一旦数据准备好,下一步就是搭建模型。

使用 BiLSTM 模型进行训练

创建和训练基准模型的代码位于笔记本的 Baseline Model 部分。创建了一个适中的模型,重点展示了无监督标注带来的提升,而非模型复杂性。此外,较小的模型训练速度更快,且能进行更多迭代:

# Length of the vocabulary 
vocab_size = imdb_encoder.vocab_size 
# Number of RNN units
rnn_units = 64
# Embedding size
embedding_dim = 64
#batch size
BATCH_SIZE=100 

该模型使用一个小的 64 维嵌入和 RNN 单元。创建模型的函数如下:

from tensorflow.keras.layers import Embedding, LSTM, \
                                    Bidirectional, Dense,\
                                    Dropout

dropout=0.5
def build_model_bilstm(vocab_size, embedding_dim, rnn_units, batch_size, dropout=0.):
    model = tf.keras.Sequential([
        Embedding(vocab_size, embedding_dim, mask_zero=True,
                  batch_input_shape=[batch_size, None]),
        Bidirectional(LSTM(rnn_units, return_sequences=True)),
        Bidirectional(tf.keras.layers.LSTM(rnn_units)),
        Dense(rnn_units, activation='relu'),
        Dropout(dropout),
        Dense(1, activation='sigmoid')
      ])
    return model 

添加适量的 dropout,以使模型具有更好的泛化能力。该模型大约有 70 万个参数。

bilstm = build_model_bilstm(
  vocab_size = vocab_size,
  embedding_dim=embedding_dim,
  rnn_units=rnn_units,
  batch_size=BATCH_SIZE)
bilstm.summary() 
Model: "sequential"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
embedding_4 (Embedding)      (100, None, 64)           529024    
_________________________________________________________________
bidirectional_8 (Bidirection (100, None, 128)          66048     
_________________________________________________________________
bidirectional_9 (Bidirection (100, 128)                98816     
_________________________________________________________________
dense_6 (Dense)              (100, 64)                 8256      
_________________________________________________________________
dropout_6 (Dropout)          (100, 64)                 0         
_________________________________________________________________
dense_7 (Dense)              (100, 1)                  65        
=================================================================
Total params: 702,209
Trainable params: 702,209
Non-trainable params: 0
_________________________________________________________________ 

该模型使用二元交叉熵损失函数和 ADAM 优化器进行编译,并跟踪准确率、精确度和召回率指标。该模型训练了 15 个周期,可以看出模型已达到饱和:

bilstm.compile(loss='binary_crossentropy', 
             optimizer='adam', 
             metrics=['accuracy', 'Precision', 'Recall'])
encoded_train_batched = encoded_train.shuffle(num_recs, seed=42).\
                                    batch(BATCH_SIZE)
bilstm.fit(encoded_train_batched, epochs=15) 
Train for 15 steps
Epoch 1/15
20/20 [==============================] - 16s 793ms/step - loss: 0.6943 - accuracy: 0.4795 - Precision: 0.4833 - Recall: 0.5940
…
Epoch 15/15
20/20 [==============================] - 4s 206ms/step - loss: 0.0044 - accuracy: 0.9995 - Precision: 0.9990 - Recall: 1.0000 

如我们所见,即使在使用 dropout 正则化后,模型仍然对小规模的训练集发生了过拟合。

Batch-and-Shuffle 或 Shuffle-and-Batch

请注意上面代码片段中的第二行代码,它对数据进行了打乱和批处理。数据首先被打乱,然后再进行批处理。在每个周期之间对数据进行打乱是一种正则化手段,有助于模型更好地学习。在 TensorFlow 中,打乱数据顺序是一个关键点。如果数据在打乱之前就被批处理,那么只有批次的顺序会在喂入模型时发生变化。然而,每个批次的组成在不同的周期中将保持不变。通过在批处理之前进行打乱,我们确保每个批次在每个周期中都不同。我们鼓励您在有和没有打乱数据的情况下进行训练。虽然打乱会稍微增加训练时间,但它会在测试集上带来更好的性能。

让我们看看这个模型在测试数据上的表现:

bilstm.evaluate(encoded_test.batch(BATCH_SIZE)) 
250/250 [==============================] - 33s 134ms/step - loss: 2.1440 - accuracy: 0.7591 - precision: 0.7455 - recall: 0.7866 

模型的准确率为 75.9%。模型的精度高于召回率。现在我们有了基准线,我们可以查看弱监督标注是否能帮助提升模型性能。这将是下一部分的重点。

使用 Snorkel 进行弱监督标注

IMDb 数据集包含 50,000 条未标记的评论。这是训练集大小的两倍,训练集有 25,000 条已标记的评论。如前一部分所述,我们从训练数据中预留了 23,000 条记录,除了用于弱监督标注的无监督集。Snorkel 中的标注是通过标注函数来完成的。每个标注函数可以返回可能的标签之一,或者选择不进行标注。由于这是一个二分类问题,因此定义了相应的常量。还展示了一个示例标注函数。此部分的所有代码可以在名为 snorkel-labeling.ipynb 的笔记本中找到:

POSITIVE = 1
NEGATIVE = 0
ABSTAIN = -1
from snorkel.labeling.lf import labeling_function
@labeling_function()
def time_waste(x):
    if not isinstance(x.review, str):
        return ABSTAIN
    ex1 = "time waste"
    ex2 = "waste of time"
    if ex1 in x.review.lower() or ex2 in x.review.lower():
        return NEGATIVE
    return ABSTAIN 

标注函数由 Snorkel 提供的 labeling_function() 注解。请注意,需要安装 Snorkel 库。详细的安装说明可以在 GitHub 上本章的子目录中找到。简而言之,Snorkel 可以通过以下方式安装:

(tf24nlp) $ pip install snorkel==0.9.5 

您看到的任何警告都可以安全忽略,因为该库使用了不同版本的组件,如 TensorBoard。为了更加确定,您可以为 Snorkel 及其依赖项创建一个单独的 conda/虚拟环境。

本章的内容得以实现,离不开 Snorkel.ai 团队的支持。Snorkel.ai 的 Frederic Sala 和 Alexander Ratner 在提供指导和用于超参数调优的脚本方面发挥了重要作用,从而最大限度地发挥了 Snorkel 的效能。

回到标签函数,以上的函数期望来自 DataFrame 的一行数据。它期望该行数据包含“review”这一文本列。该函数尝试检查评论是否表示电影或节目是浪费时间。如果是,它返回负面标签;否则,它不会对该行数据进行标记。请注意,我们正在尝试使用这些标签函数在短时间内标记数千行数据。实现这一目标的最佳方法是打印一些随机的正面和负面评论样本,并使用文本中的某些词汇作为标签函数。这里的核心思想是创建多个函数,它们能对子集行数据有较好的准确性。让我们检查训练集中一些负面评论,看看可以创建哪些标签函数:

neg = train_df[train_df.sentiment==0].sample(n=5, random_state=42)
for x in neg.review.tolist():
    print(x) 

其中一条评论开头是“一个非常俗气且乏味的公路电影”,这为标签函数提供了一个想法:

@labeling_function()
def cheesy_dull(x):
    if not isinstance(x.review, str):
        return ABSTAIN
    ex1 = "cheesy"
    ex2 = "dull"
    if ex1 in x.review.lower() or ex2 in x.review.lower():
        return NEGATIVE
    return ABSTAIN 

负面评论中出现了许多不同的词汇。这是负面标签函数的一个子集,完整列表请参见笔记本:

@labeling_function()
def garbage(x):
    if not isinstance(x.review, str):
        return ABSTAIN
    ex1 = "garbage"
    if ex1 in x.review.lower():
        return NEGATIVE
    return ABSTAIN
@labeling_function()
def terrible(x):
    if not isinstance(x.review, str):
        return ABSTAIN
    ex1 = "terrible"
    if ex1 in x.review.lower():
        return NEGATIVE
    return ABSTAIN
@labeling_function()
def unsatisfied(x):
    if not isinstance(x.review, str):
        return ABSTAIN
    ex1 = "unsatisf"  # unsatisfactory, unsatisfied
    if ex1 in x.review.lower():
        return NEGATIVE
    return ABSTAIN 

所有负面标签函数都添加到一个列表中:

neg_lfs = [atrocious, terrible, piece_of, woefully_miscast, 
           bad_acting, cheesy_dull, disappoint, crap, garbage, 
           unsatisfied, ridiculous] 

检查负面评论的样本可以给我们很多想法。通常,领域专家的一点小小努力就能产生多个易于实现的标签函数。如果你曾经看过电影,那么对于这个数据集来说,你就是专家。检查正面评论的样本会导致更多的标签函数。以下是识别评论中正面情感的标签函数示例:

import re
@labeling_function()
def classic(x):
    if not isinstance(x.review, str):
        return ABSTAIN
    ex1 = "a classic"
    if ex1 in x.review.lower():
        return POSITIVE
    return ABSTAIN
@labeling_function()
def great_direction(x):
    if not isinstance(x.review, str):
        return ABSTAIN
    ex1 = "(great|awesome|amazing|fantastic|excellent) direction"
    if re.search(ex1, x.review.lower()):
        return POSITIVE
    return ABSTAIN
@labeling_function()
def great_story(x):
    if not isinstance(x.review, str):
        return ABSTAIN
    ex1 = "(great|awesome|amazing|fantastic|excellent|dramatic) (script|story)"
    if re.search(ex1, x.review.lower()):
        return POSITIVE
    return ABSTAIN 

所有正面标签函数可以在笔记本中看到。与负面函数类似,定义了正面标签函数的列表:

pos_lfs = [classic, must_watch, oscar, love, great_entertainment,
           very_entertaining, amazing, brilliant, fantastic, 
           awesome, great_acting, great_direction, great_story,
           favourite]
# set of labeling functions
lfs = neg_lfs + pos_lfs 

标签的开发是一个迭代过程。不要被这里显示的标签函数数量吓到。你可以看到,它们大部分都非常简单。为了帮助你理解工作量,我花费了总共 3 小时来创建和测试标签函数:

请注意,笔记本中包含了大量简单的标签函数,这里仅展示了其中的一个子集。请参考实际代码获取所有标签函数。

该过程包括查看一些样本并创建标签函数,然后在数据的子集上评估结果。查看标签函数与标记示例不一致的示例,对于使函数更精确或添加补偿函数非常有用。那么,让我们看看如何评估这些函数,以便进行迭代。

对标签函数进行迭代

一旦定义了一组标注函数,它们就可以应用于 pandas DataFrame,并且可以训练一个模型来计算在计算标签时分配给各个标注函数的权重。Snorkel 提供了帮助执行这些任务的函数。首先,让我们应用这些标注函数来计算一个矩阵。这个矩阵的列数等于每一行数据的标注函数数量:

# let's take a sample of 100 records from training set
lf_train = train_df.sample(n=1000, random_state=42)
from snorkel.labeling.model import LabelModel
from snorkel.labeling import PandasLFApplier
# Apply the LFs to the unlabeled training data
applier = PandasLFApplier(lfs)
L_train = applier.apply(lf_train) 

在上述代码中,从训练数据中提取了 1000 行数据样本。然后,将之前创建的所有标注函数列表传递给 Snorkel,并应用于这个训练数据的样本。如果我们创建了 25 个标注函数,那么 L_train 的形状将会是 (1000, 25)。每一列代表一个标注函数的输出。现在可以在这个标签矩阵上训练一个生成模型:

# Train the label model and compute the training labels
label_model = LabelModel(cardinality=2, verbose=True)
label_model.fit(L_train, n_epochs=500, log_freq=50, seed=123)
lf_train["snorkel"] = label_model.predict(L=L_train, 
                                      **tie_break_policy=****"abstain"**) 

创建一个 LabelModel 实例,参数指定实际模型中有多少个标签。然后训练这个模型,并为数据子集预测标签。这些预测的标签将作为 DataFrame 的新列添加进去。请注意传递给 predict() 方法的 tie_break_policy 参数。如果模型在标注函数的输出中有冲突,并且这些冲突在模型中得分相同,该参数指定如何解决冲突。在这里,我们指示模型在发生冲突时放弃标记记录。另一个可能的设置是 "random",在这种情况下,模型将随机分配一个被绑定标注函数的输出。这两个选项在手头问题的背景下的主要区别是精确度。通过要求模型在标记时放弃,我们可以获得更高的精确度结果,但标记的记录会更少。随机选择一个被绑定函数的输出会导致更广泛的覆盖率,但质量可能较低。可以通过分别使用这两个选项的输出来训练同一个模型来测试这一假设。鼓励您尝试这些选项并查看结果。

由于选择了放弃策略,可能并没有对所有的 1000 行进行标注:

pred_lfs = lf_train[lf_train.snorkel > -1]
pred_lfs.describe() 
sentiment 

|

score 

|

snorkel 

|

--- --- --- ---

|

count 

|

598.000000 

|

598.000000 

|

598.000000 

|

在 1000 条记录中,只有 458 条被标记。让我们检查其中有多少条标记错误的。

pred_mistake = pred_lfs[pred_lfs.sentiment != pred_lfs.snorkel]
pred_mistake.describe() 
sentiment 

|

score 

|

snorkel 

|

--- --- --- ---

|

count 

|

164.000000 

|

164.000000 

|

164.000000 

|

使用 Snorkel 和我们的标注函数标注了 598 条记录,其中 434 条标签正确,164 条记录被错误标注。标签模型的准确率约为 72.6%。为了获得更多标注函数的灵感,你应该检查一些标签模型产生错误结果的行,并更新或添加标注函数。如前所述,约花了 3 个小时迭代并创建了 25 个标注函数。为了从 Snorkel 中获得更多效果,我们需要增加训练数据量。目标是开发一种方法,快速获得大量标签,而不需要太多人工干预。在这个特定案例中,可以使用的一种技术是训练一个简单的朴素贝叶斯模型,获取与正面或负面标签高度相关的词汇。这是下一节的重点。朴素贝叶斯NB)是许多基础 NLP 书籍中涵盖的一种基本技术。

用于寻找关键词的朴素贝叶斯模型

在这个数据集上构建 NB 模型需要不到一个小时,并且有潜力显著提高标注函数的质量和覆盖面。NB 模型的核心代码可以在spam-inspired-technique-naive-bayes.ipynb笔记本中找到。请注意,这些探索与主要的标注代码无关,如果需要,可以跳过这一部分,因为这一部分的学习成果会应用于构建更好的标注函数,这些函数在snorkel-labeling.ipynb笔记本中有详细介绍。

基于 NB 的探索的主要流程是加载评论、去除停用词、选择前 2000 个词来构建一个简单的向量化方案,并训练一个 NB 模型。由于数据加载与前面章节讲解的相同,因此本节省略了细节。

本节使用了 NLTK 和wordcloud Python 包。由于我们在第一章《NLP 基础》中已经使用过 NLTK,它应该已经安装。wordcloud可以通过以下命令安装:

(tf24nlp) $ pip install wordcloud==1.8

词云有助于整体理解正面和负面评论的文本。请注意,前 2000 个词的向量化方案需要计数器。定义了一个方便的函数,它清理 HTML 文本,并删除停用词,将其余部分进行分词,代码如下:

en_stopw = set(stopwords.words("english"))
def get_words(review, words, stopw=en_stopw):
    review = BeautifulSoup(review).text        # remove HTML tags
    review = re.sub('[^A-Za-z]', ' ', review)  # remove non letters
    review = review.lower()
    tok_rev = wt(review)
    rev_word = [word for word in tok_rev if word not in stopw]
    words += rev_word 

然后,正面评价被分离出来,并生成一个词云以便于可视化:

pos_rev = train_df[train_df.sentiment == 1]
pos_words = []
pos_rev.review.apply(get_words, args=(pos_words,))
from wordcloud import WordCloud
import matplotlib.pyplot as plt
pos_words_sen = " ".join(pos_words)
pos_wc = WordCloud(width = 600,height = 512).generate(pos_words_sen)
plt.figure(figsize = (12, 8), facecolor = 'k')
plt.imshow(pos_wc)
plt.axis('off')
plt.tight_layout(pad = 0)
plt.show() 

上述代码的输出如图 8.3所示:

A close up of a sign  Description automatically generated

图 8.3:正面评价词云

不足为奇的是,moviefilm 是最大的词。然而,在这里可以看到许多其他建议的关键词。类似地,也可以生成负面评价的词云,如图 8.4所示:

A close up of a sign  Description automatically generated

图 8.4:负面评价词云

这些可视化结果很有趣;然而,训练模型后会得到更清晰的画面。只需要前 2000 个单词就可以训练模型:

from collections import Counter
pos = Counter(pos_words)
neg = Counter(neg_words)
# let's try to build a naive bayes model for sentiment classification
tot_words = pos + neg
tot_words.most_common(10) 
[('movie', 44031),
 ('film', 40147),
 ('one', 26788),
 ('like', 20274),
 ('good', 15140),
 ('time', 12724),
 ('even', 12646),
 ('would', 12436),
 ('story', 11983),
 ('really', 11736)] 

合并计数器显示了所有评论中最常出现的前 10 个单词。这些被提取到一个列表中:

top2k = [x for (x, y) in tot_words.most_common(2000)] 

每个评论的向量化相当简单——每个 2000 个单词中的一个都会成为给定评论的一个列。如果该列所表示的单词出现在评论中,则该列的值标记为 1,否则为 0。所以,每个评论由一个由 0 和 1 组成的序列表示,表示该评论包含了哪些前 2000 个单词。下面的代码展示了这一转换:

def featurize(review, topk=top2k, stopw=en_stopw):
    review = BeautifulSoup(review).text        # remove HTML tags
    review = re.sub('[^A-Za-z]', ' ', review)  # remove nonletters
    review = review.lower()
    tok_rev = wt(review)
    rev_word = [word for word in tok_rev if word not in stopw]
    features = {}
    for word in top2k:
        features['contains({})'.format(word)] = (word in rev_word)
    return features
train = [(featurize(rev), senti) for (rev, senti) in 
                        zip(train_df.review, train_df.sentiment)] 

训练模型相当简单。请注意,这里使用的是伯努利朴素贝叶斯模型,因为每个单词都是根据它在评论中是否存在来表示的。或者,也可以使用单词在评论中的频率。如果在上述评论向量化过程中使用单词的频率,那么应该使用朴素贝叶斯的多项式形式。

NLTK 还提供了一种检查最具信息性特征的方法:

classifier = nltk.NaiveBayesClassifier.train(train)
# 0: negative sentiment, 1: positive sentiment
classifier.show_most_informative_features(20) 
Most Informative Features
       contains(unfunny) = True      0 : 1      =     14.1 : 1.0
         contains(waste) = True      0 : 1      =     12.7 : 1.0
     contains(pointless) = True      0 : 1      =     10.4 : 1.0
     contains(redeeming) = True      0 : 1      =     10.1 : 1.0
     contains(laughable) = True      0 : 1      =      9.3 : 1.0
         contains(worst) = True      0 : 1      =      9.0 : 1.0
         contains(awful) = True      0 : 1      =      8.4 : 1.0
        contains(poorly) = True      0 : 1      =      8.2 : 1.0
   contains(wonderfully) = True      1 : 0      =      7.6 : 1.0
         contains(sucks) = True      0 : 1      =      7.0 : 1.0
          contains(lame) = True      0 : 1      =      6.9 : 1.0
      contains(pathetic) = True      0 : 1      =      6.4 : 1.0
    contains(delightful) = True      1 : 0      =      6.0 : 1.0
        contains(wasted) = True      0 : 1      =      6.0 : 1.0
          contains(crap) = True      0 : 1      =      5.9 : 1.0
   contains(beautifully) = True      1 : 0      =      5.8 : 1.0
      contains(dreadful) = True      0 : 1      =      5.7 : 1.0
          contains(mess) = True      0 : 1      =      5.6 : 1.0
      contains(horrible) = True      0 : 1      =      5.5 : 1.0
        contains(superb) = True      1 : 0      =      5.4 : 1.0
       contains(garbage) = True      0 : 1      =      5.3 : 1.0
         contains(badly) = True      0 : 1      =      5.3 : 1.0
        contains(wooden) = True      0 : 1      =      5.2 : 1.0
      contains(touching) = True      1 : 0      =      5.1 : 1.0
      contains(terrible) = True      0 : 1      =      5.1 : 1.0 

整个过程是为了找出哪些单词对预测负面和正面评论最有用。上面的表格显示了这些单词及其可能性比率。以输出的第一行中的单词unfunny为例,模型表示含有unfunny的评论比含有它的正面评论要负面 14.1 倍。标签函数是通过这些关键词更新的。

在分析snorkel-labeling.ipynb中标签函数分配的标签后,可以看到负面评论比正面评论标注得更多。因此,标签函数为正面标签使用的单词列表比负面标签使用的单词列表要大。请注意,数据集不*衡会影响整体训练准确性,特别是召回率。以下代码片段展示了使用上述通过朴素贝叶斯发现的关键词来增强标签函数:

# Some positive high prob words - arbitrary cutoff of 4.5x
'''
   contains(wonderfully) = True       1 : 0      =      7.6 : 1.0
    contains(delightful) = True       1 : 0      =      6.0 : 1.0
   contains(beautifully) = True       1 : 0      =      5.8 : 1.0
        contains(superb) = True       1 : 0      =      5.4 : 1.0
      contains(touching) = True       1 : 0      =      5.1 : 1.0
   contains(brilliantly) = True       1 : 0      =      4.7 : 1.0
    contains(friendship) = True       1 : 0      =      4.6 : 1.0
        contains(finest) = True       1 : 0      =      4.5 : 1.0
      contains(terrific) = True       1 : 0      =      4.5 : 1.0
           contains(gem) = True       1 : 0      =      4.5 : 1.0
   contains(magnificent) = True       1 : 0      =      4.5 : 1.0
'''
wonderfully_kw = make_keyword_lf(keywords=["wonderfully"], 
label=POSITIVE)
delightful_kw = make_keyword_lf(keywords=["delightful"], 
label=POSITIVE)
superb_kw = make_keyword_lf(keywords=["superb"], label=POSITIVE)
pos_words = ["beautifully", "touching", "brilliantly", 
"friendship", "finest", "terrific", "magnificent"]
pos_nb_kw = make_keyword_lf(keywords=pos_words, label=POSITIVE)
@labeling_function()
def superlatives(x):
    if not isinstance(x.review, str):
        return ABSTAIN
    ex1 = ["best", "super", "great","awesome","amaz", "fantastic", 
           "excellent", "favorite"]
    pos_words = ["beautifully", "touching", "brilliantly", 
                 "friendship", "finest", "terrific", "magnificent", 
                 "wonderfully", "delightful"]
    ex1 += pos_words
    rv = x.review.lower()
    counts = [rv.count(x) for x in ex1]
    if sum(counts) >= 3:
        return POSITIVE
    return ABSTAIN 

由于基于关键字的标签函数非常常见,Snorkel 提供了一种简单的方法来定义这样的函数。以下代码片段使用两种编程方式将单词列表转换为标签函数集合:

# Utilities for defining keywords based functions
def keyword_lookup(x, keywords, label):
    if any(word in x.review.lower() for word in keywords):
        return label
    return ABSTAIN
def make_keyword_lf(keywords, label):
    return LabelingFunction(
        name=f"keyword_{keywords[0]}",
        f=keyword_lookup,
        resources=dict(keywords=keywords, label=label),
    ) 

第一个函数进行简单匹配并返回特定标签,或者选择不标注。请查看snorkel-labeling.ipynb文件,了解迭代开发的完整标签函数列表。总的来说,我花了大约 12-14 小时进行标签函数的开发和研究。

在我们尝试使用这些数据训练模型之前,让我们先评估一下该模型在整个训练数据集上的准确性。

评估训练集上的弱监督标签

我们应用标签函数并在整个训练数据集上训练一个模型,只是为了评估该模型的质量:

L_train_full = applier.apply(train_df)
label_model = LabelModel(cardinality=2, verbose=True)
label_model.fit(L_train_full, n_epochs=500, log_freq=50, seed=123)
metrics = label_model.score(L=L_train_full, Y=train_df.sentiment, 
                            tie_break_policy="abstain",
                            metrics=["accuracy", "coverage", 
                                     "precision", 
                                     "recall", "f1"])
print("All Metrics: ", metrics) 
Label Model Accuracy:     78.5%
All Metrics:  {**'accuracy': 0.7854110013835218, 'coverage': 0.83844**, 'precision': 0.8564883605745418, 'recall': 0.6744344773790951, 'f1': 0.7546367008509709} 

我们的标签函数集覆盖了 25,000 条训练记录中的 83.4%,其中 85.6%是正确标签。Snorkel 提供了分析每个标签函数表现的能力:

from snorkel.labeling import LFAnalysis
LFAnalysis(L=L_train_full, lfs=lfs).lf_summary() 
 j Polarity  Coverage  Overlaps  Conflicts
atrocious             0      [0]   0.00816   0.00768    0.00328
terrible              1      [0]   0.05356   0.05356    0.02696
piece_of              2      [0]   0.00084   0.00080    0.00048
woefully_miscast      3      [0]   0.00848   0.00764    0.00504
**bad_acting            4      [0]   0.08748   0.08348    0.04304**
cheesy_dull           5      [0]   0.05136   0.04932    0.02760
bad                  11      [0]   0.03624   0.03624    0.01744
keyword_waste        12      [0]   0.07336   0.06848    0.03232
keyword_pointless    13      [0]   0.01956   0.01836    0.00972
keyword_redeeming    14      [0]   0.01264   0.01192    0.00556
keyword_laughable    15      [0]   0.41036   0.37368    0.20884
negatives            16      [0]   0.35300   0.34720    0.17396
classic              17      [1]   0.01684   0.01476    0.00856
must_watch           18      [1]   0.00176   0.00140    0.00060
oscar                19      [1]   0.00064   0.00060    0.00016
love                 20      [1]   0.08660   0.07536    0.04568
great_entertainment  21      [1]   0.00488   0.00488    0.00292
very_entertaining    22      [1]   0.00544   0.00460    0.00244
**amazing              23      [1]   0.05028   0.04516    0.02340**
great                31      [1]   0.27728   0.23568    0.13800
keyword_wonderfully  32      [1]   0.01248   0.01248    0.00564
keyword_delightful   33      [1]   0.01188   0.01100    0.00500
keyword_superb       34      [1]   0.02948   0.02636    0.01220
keyword_beautifully  35      [1]   0.08284   0.07428    0.03528
superlatives         36      [1]   0.14656   0.14464    0.07064
keyword_remarkable   37      [1]   0.32052   0.26004    0.14748 

请注意,这里展示的是输出的简短版本。完整的输出可以在笔记本中查看。对于每个标签函数,表格展示了其产生的标签和函数的覆盖范围——即,它为多少条记录提供标签,多少条记录与其他函数产生相同标签重叠,以及多少条记录与其他函数产生不同标签冲突。正标签和负标签函数已被突出显示。bad_acting()函数覆盖了 8.7%的记录,但大约 8.3%的时间与其他函数重叠。然而,它与产生正标签的函数发生冲突的时间大约是 4.3%。amazing()函数覆盖了大约 5%的数据集,发生冲突的时间大约为 2.3%。这些数据可以用来进一步微调特定的函数,并检查我们如何划分数据。图 8.5展示了正标签、负标签和弃权标签之间的*衡:

一张社交媒体帖子的截图 说明自动生成

图 8.5:Snorkel 生成的标签分布

Snorkel 提供了几种超参数调优的选项,以进一步提高标签的质量。我们通过网格搜索参数来找到最佳训练参数,同时排除那些在最终输出中增加噪声的标签函数。

超参数调优通过选择不同的学习率、L2 正则化、训练轮数和使用的优化器来进行。最后,通过设定阈值来决定哪些标签函数应该保留用于实际的标签任务:

# Grid Search
from itertools import product
lrs = [1e-1, 1e-2, 1e-3]
l2s = [0, 1e-1, 1e-2]
n_epochs = [100, 200, 500]
optimizer = ["sgd", "adam"]
thresh = [0.8, 0.9]
lma_best = 0
params_best = []
for params in product(lrs, l2s, n_epochs, optimizer, thresh):
    # do the initial pass to access the accuracies
    label_model.fit(L_train_full, n_epochs=params[2], log_freq=50, 
                    seed=123, optimizer=params[3], lr=params[0], 
                    l2=params[1])

    # accuracies
    weights = label_model.get_weights()

    # LFs above our threshold 
    vals = weights > params[4]

    # the LM requires at least 3 LFs to train
    if sum(vals) >= 3:
        L_filtered = L_train_full[:, vals]
        label_model.fit(L_filtered, n_epochs=params[2], 
                        log_freq=50, seed=123, 
                        optimizer=params[3], lr=params[0], 
                        l2=params[1])
        label_model_acc = label_model.score(L=L_filtered, 
                          Y=train_df.sentiment, 
                          tie_break_policy="abstain")["accuracy"]
        if label_model_acc > lma_best:
            lma_best = label_model_acc
            params_best = params

print("best = ", lma_best, " params ", params_best) 

Snorkel 可能会打印出一个警告,提示指标仅在非弃权标签上进行计算。这是设计使然,因为我们关注的是高置信度的标签。如果标签函数之间存在冲突,我们的模型会选择放弃为其提供标签。打印出的最佳参数是:

best =  0.8399649430324277  params  (0.001, 0.1, 200, 'adam', 0.9) 

通过这个调优,模型的准确率从 78.5%提高到了 84%!

使用这些参数,我们标注了来自训练集的 23k 条记录和来自无监督集的 50k 条记录。对于第一部分,我们标注了所有 25k 条训练记录,并将其分成两组。这个特定的分割部分在上面的基准模型部分中提到过:

train_df["snorkel"] = label_model.predict(L=L_filtered, 
                               tie_break_policy="abstain")
from sklearn.model_selection import train_test_split
# Randomly split training into 2k / 23k sets
train_2k, train_23k = train_test_split(train_df, test_size=23000, 
                                       random_state=42, 
                                       stratify=train_df.sentiment)
train_23k.snorkel.hist()
train_23k.sentiment.hist() 

代码的最后两行检查标签的状态,并与实际标签进行对比,生成图 8.6中所示的图表:

一张包含屏幕、建筑、绘画和食物的图片 说明自动生成

图 8.6:训练集中标签与使用 Snorkel 生成的标签的对比

当 Snorkel 模型放弃标签时,它会为标签分配-1。我们看到模型能够标注更多的负面评论,而不是正面标签。我们过滤掉 Snorkel 放弃标注的行并保存记录:

lbl_train = train_23k[train_23k.snorkel > -1]
lbl_train = lbl_train.drop(columns=["sentiment"])
p_sup = lbl_train.rename(columns={"snorkel": "sentiment"})
p_sup.to_pickle("snorkel_train_labeled.df") 

然而,我们面临的关键问题是,如果我们用这些噪声标签(准确率为 84%)增强训练数据,这会使我们的模型表现更好还是更差?请注意,基准模型的准确率大约为 74%。

为了回答这个问题,我们对无监督数据集进行标注,然后训练与基准相同的模型架构。

为未标记数据生成无监督标签

正如我们在上一部分看到的,我们对训练数据集进行了标注,运行模型在数据集的未标注评论上也非常简单:

# Now apply this to all the unsupervised reviews
# Apply the LFs to the unlabeled training data
applier = PandasLFApplier(lfs)
# now let's apply on the unsupervised dataset
L_train_unsup = applier.apply(unsup_df)
label_model = LabelModel(cardinality=2, verbose=True)
label_model.fit(L_train_unsup[:, vals], n_epochs=params_best[2], 
                optimizer=params_best[3], 
                lr=params_best[0], l2=params_best[1], 
                log_freq=100, seed=42)
unsup_df["snorkel"] = label_model.predict(L=L_train_unsup[:, vals], 
                                   tie_break_policy="abstain")
# rename snorkel to sentiment & concat to the training dataset
pred_unsup_lfs = unsup_df[unsup_df.snorkel > -1]
p2 = pred_unsup_lfs.rename(columns={"snorkel": "sentiment"})
print(p2.info())
p2.to_pickle("snorkel-unsup-nbs.df") 

现在标签模型已经训练完成,预测结果被添加到无监督数据集的额外列中。模型对 50,000 条记录中的 29,583 条进行了标注。这几乎等于训练数据集的大小。假设无监督数据集的错误率与训练集上的错误率相似,我们就向训练集中添加了约 24,850 条正确标签的记录和约 4,733 条错误标签的记录。然而,这个数据集的*衡性非常倾斜,因为正面标签的覆盖仍然很差。大约有 9,000 个正面标签,覆盖超过 20,000 个负面标签。笔记本中的增加正面标签覆盖率部分通过添加更多关键词函数来进一步提高正面标签的覆盖率。

这导致数据集稍微更*衡,如下图所示:

A screen shot of a social media post  Description automatically generated

图 8.7:对无监督数据集应用进一步改进的标注函数,改善了正面标签的覆盖

该数据集已保存到磁盘,以便在训练过程中使用:

p3 = pred_unsup_lfs2.rename(columns={"snorkel2": "sentiment"})
print(p3.info())
p3.to_pickle("snorkel-unsup-nbs-v2.df") 

已标记的数据集保存到磁盘,并在训练代码中重新加载,以便更好的模块化和易于阅读。在生产管道中,可能不会持久化中间输出,而是直接将其馈入训练步骤。另一个小考虑因素是分离虚拟/conda 环境来运行 Snorkel。为弱监督标注创建一个单独的脚本也可以使用不同的 Python 环境。

我们将重点重新放回到imdb-with-snorkel-labels.ipynb笔记本,该笔记本包含用于训练的模型。该部分的代码从使用 Snorkel 标注数据部分开始。新标注的记录需要从磁盘加载、清理、向量化并填充,才能进行训练。我们提取标注的记录并去除 HTML 标记,如下所示:

# labelled version of training data split
p1 = pd.read_pickle("snorkel_train_labeled.df")
p2 = pd.read_pickle("snorkel-unsup-nbs-v2.df")
p2 = p2.drop(columns=['snorkel']) # so that everything aligns
# now concatenate the three DFs
p2 = pd.concat([train_small, p1, p2]) # training plus snorkel labelled data
print("showing hist of additional data")
# now balance the labels
pos = p2[p2.sentiment == 1]
neg = p2[p2.sentiment == 0]
recs = min(pos.shape[0], neg.shape[0])
pos = pos.sample(n=recs, random_state=42)
neg = neg.sample(n=recs, random_state=42)
p3 = pd.concat((pos,neg))
p3.sentiment.hist() 

原始训练数据集在正负标签上是*衡的。然而,使用 Snorkel 标记的数据存在不*衡问题。我们*衡数据集,并忽略那些多余的负标签记录。请注意,基准模型使用的 2,000 条训练记录也需要添加进去,总共得到 33,914 条训练记录。如前所述,当数据量是原始数据集的 10 倍到 50 倍时,这个方法非常有效。在这里,如果将 2,000 条训练记录也算在内,我们达到了接* 17 倍的比例,或者是 18 倍。

A picture containing screen, orange, drawing  Description automatically generated

图 8.8:使用 Snorkel 和弱监督后的记录分布

如上图Figure 8.8所示,蓝色的记录被丢弃以*衡数据集。接下来,数据需要使用子词词汇进行清洗和向量化:

# remove markup
cleaned_unsup_reviews = p3.review.apply(
                             lambda x: BeautifulSoup(x).text)
snorkel_reviews = pd.concat((cleaned_reviews, cleaned_unsup_reviews))
snorkel_labels = pd.concat((train_small.sentiment, p3.sentiment)) 

最后,我们将 pandas DataFrame 转换为 TensorFlow 数据集,并进行向量化和填充:

# convert pandas DF in to tf.Dataset
snorkel_train = tf.data.Dataset.from_tensor_slices((
                                   snorkel_reviews.values,
                                   snorkel_labels.values))
encoded_snorkel_train = snorkel_train.map(encode_tf_fn,
                 num_parallel_calls=tf.data.experimental.AUTOTUNE) 

我们准备好尝试训练我们的 BiLSTM 模型,看看它是否能在这个任务上提高性能。

在 Snorkel 的弱监督数据上训练 BiLSTM

为了确保我们在比较相同的事物,我们使用与基准模型相同的 BiLSTM。我们实例化一个模型,嵌入维度为 64,RNN 单元数为 64,批量大小为 100。该模型使用二元交叉熵损失和 Adam 优化器。模型训练过程中,会跟踪准确率、精确度和召回率。一个重要的步骤是每个周期都打乱数据集,以帮助模型保持最小的误差。

这是一个重要的概念。深度模型基于假设损失是一个凸面,梯度沿着这个凸面下降到底部。实际上,这个面有很多局部最小值或鞍点。如果模型在一个小批次中陷入局部最小值,由于跨越多个周期时,模型不断接收到相同的数据点,它将很难从局部最小值中跳出来。通过打乱数据,改变数据集及其顺序,模型可以更好地学习,从而更快地跳出这些局部最小值。此部分的代码位于imdb-with-snorkel-labels.ipynb文件中:

shuffle_size = snorkel_reviews.shape[0] // BATCH_SIZE * BATCH_SIZE
encoded_snorkel_batched = encoded_snorkel_train.shuffle( 
                                  buffer_size=shuffle_size,
                                  seed=42).batch(BATCH_SIZE,
                                  drop_remainder=True) 

请注意,我们缓存了所有将成为批次一部分的记录,以便实现完美的缓冲。这会带来训练速度略微变慢和内存使用量增加的代价。同时,由于我们的批量大小是 100,数据集有 35,914 条记录,我们丢弃了剩余的记录。我们训练模型 20 个周期,略多于基准模型。基准模型在 15 个周期时就发生了过拟合,因此再训练也没有意义。这个模型有更多的数据可以训练,因此需要更多的周期来学习:

bilstm2.fit(encoded_snorkel_batched, epochs=20) 
Train for 359 steps
Epoch 1/20
359/359 [==============================] - 92s 257ms/step - loss: 0.4399 - accuracy: 0.7860 - Precision: 0.7900 - Recall: 0.7793
…
Epoch 20/20
359/359 [==============================] - 82s 227ms/step - loss: 0.0339 - accuracy: 0.9886 - Precision: 0.9879 - Recall: 0.9893 

该模型达到了 98.9%的准确率。精确度和召回率的数值非常接*。在测试数据上评估基线模型时,得到了 76.23%的准确率,这明显证明它对训练数据发生了过拟合。当评估使用弱监督标签训练的模型时,得到以下结果:

bilstm2.evaluate(encoded_test.batch(BATCH_SIZE)) 
250/250 [==============================] - 35s 139ms/step - loss: 1.9134 - accuracy: 0.7658 - precision: 0.7812 - recall: 0.7386 

这个在弱监督噪声标签上训练的模型达到了 76.6%的准确率,比基线模型高出 0.7%。还要注意,精确度从 74.5%提高到了 78.1%,但召回率有所下降。在这个玩具设置中,我们保持了许多变量不变,例如模型类型、dropout 比例等。在实际环境中,我们可以通过优化模型架构和调整超参数进一步提高准确率。还有其他选项可以尝试。记住,我们指示 Snorkel 在不确定时避免标注。

通过将其改为多数投票或其他策略,训练数据的量可以进一步增加。你也可以尝试在不*衡的数据集上训练,看看其影响。这里的重点是展示弱监督在大规模增加训练数据量方面的价值,而不是构建最佳模型。然而,你应该能够将这些教训应用到你的项目中。

花些时间思考结果的原因是非常重要的。在这个故事中隐藏着一些重要的深度学习教训。首先,在模型复杂度足够的情况下,更多的标注数据总是好的。数据量和模型容量之间是有相关性的。高容量的模型可以处理数据中的更复杂关系,同时也需要更大的数据集来学习这些复杂性。然而,如果模型保持不变且容量足够,标注数据的数量就会产生巨大的影响,正如这里所证明的那样。通过增加标注数据量,我们能提高的效果是有限的。在 Chen Sun 等人于 ICCV 2017 发布的论文《重新审视深度学习时代数据的非理性有效性》中,作者研究了数据在计算机视觉领域中的作用。他们报告说,随着训练数据量的增加,模型的性能呈对数增长。他们报告的第二个结果是,通过预训练学习表示会显著帮助下游任务。本章中的技术可以应用于生成更多的数据用于微调步骤,这将大大提升微调模型的性能。

第二个教训是关于机器学习基础的——训练数据集的打乱对模型性能有不成比例的影响。在本书中,我们并不是总这样做,以便管理训练时间。对于训练生产模型,重要的是关注一些基础,如在每个 epoch 之前打乱数据集。

让我们回顾一下在这一章中学到的内容。

总结

很明显,深度模型在拥有大量数据时表现非常好。BERT 和 GPT 模型展示了在大量数据上进行预训练的价值。对于预训练或微调,获得高质量的标注数据仍然非常困难。我们结合弱监督学习和生成模型的概念,以低成本标注数据。通过相对较少的努力,我们能够将训练数据量扩展 18 倍。即使额外的训练数据存在噪声,BiLSTM 模型仍然能够有效学习,并比基线模型提高了 0.6%。

表示学习或预训练会导致迁移学习,并使微调模型在下游任务中表现良好。然而,在许多领域,如医学,标注数据的数量可能较少或获取成本较高。运用本章所学的技术,训练数据的数量可以通过少量的努力迅速扩展。构建一个超越当前最先进水*的模型有助于回顾一些深度学习的基本经验教训,比如更大的数据如何显著提升性能,以及更大的模型并不总是更好的。

现在,我们将重点转向对话式人工智能。构建对话式 AI 系统是一项非常具有挑战性的任务,涉及多个层面。到目前为止,本书所涵盖的内容可以帮助构建聊天机器人系统的各个部分。下一章将介绍对话式 AI 或聊天机器人系统的关键组成部分,并概述构建它们的有效方法。

第九章:使用深度学习构建对话式 AI 应用

对话的艺术被认为是人类独有的特质。机器与人类进行对话的能力已经成为多年来的研究课题。艾伦·图灵提出了现在著名的图灵测试,旨在检验人类能否通过书面信息与另一个人类和机器进行对话,并正确识别每个参与者是机器还是人类。*年来,像亚马逊的 Alexa 和苹果的 Siri 这样的数字助手在对话式 AI 方面取得了显著进展。本章讨论了不同的对话式代理,并将前几章学到的技术置于实际背景中。虽然构建对话式代理有多种方法,但我们将重点讨论*年来的深度学习方法,并涵盖以下主题:

  • 对话式代理及其通用架构概览

  • 构建对话式代理的端到端流程

  • 不同类型对话式代理的架构,例如

    • 问答机器人

    • 填槽型或任务导向型机器人

    • 通用对话机器人

我们将从对话式代理的一般架构概述开始。

对话式代理概述

对话式代理通过语音或文本与人进行互动。Facebook Messenger 是一个基于文本的代理示例,而 Alexa 和 Siri 是通过语音互动的代理示例。无论是哪种情况,代理都需要理解用户的意图并作出相应的回应。因此,代理的核心部分将是自然语言理解NLU)模块。该模块将与自然****语言生成NLG)模块进行交互,向用户提供回应。语音代理与基于文本的代理的不同之处在于,它们拥有一个额外的模块,用于将语音转换为文本,反之亦然。我们可以想象该系统对于语音激活代理的逻辑结构如下:

一张手机的截图 自动生成的描述

图 9.1:对话式 AI 系统的概念架构

语音系统和文本系统之间的主要区别在于用户与系统的互动方式。上述图 9.1中“语音识别与生成”部分右侧的所有其他部分,在这两种类型的对话式 AI 系统中都是相同的。

用户通过语音与代理进行交流。代理首先将语音转换为文本。在过去几年中,这一领域取得了许多进展,对于像英语这样的大语言,它通常被认为是一个已解决的问题。

英语在全球许多国家都被使用,这导致了多种不同的发音和方言。因此,像苹果这样的公司开发了不同口音的模型,例如英式英语、印度英语和澳大利亚英语。下图 Figure 9.2 显示了在运行 iOS 13.6 的 iPhone 11 上的 Siri 控制面板中,一些英语和法语口音。法语、德语以及其他一些语言也有多个变体。另一种方式是将口音和语言分类模型作为第一步,然后通过适当的语音识别模型处理输入:

图 9.2:Siri 中的语言变体用于语音识别

对于虚拟助手,有特定的模型用于唤醒词检测。该模型的目标是在检测到唤醒词或短语(例如“OK Google”)时启动机器人。唤醒词会触发机器人监听用户的发言,直到对话完成。一旦用户的语音被转换为文本,就可以应用本书中多个章节提到的各种自然语言处理(NLP)技术。Figure 9.1 中 NLP 框内显示的元素可以视为概念性的。根据系统和任务的不同,这些组件可能是不同的模型,或者是一个端到端的模型。然而,考虑如图所示的逻辑划分是很有帮助的。

理解用户的命令和意图是至关重要的一部分。意图识别对于像亚马逊的 Alexa 或苹果的 Siri 这样的通用系统至关重要,因为它们具有多种功能。根据识别的意图,可能会调用特定的对话管理系统。对话管理可能会调用由履行系统提供的 API。在银行聊天机器人中,命令可能是获取最新余额,而履行可能是一个从银行系统中提取最新余额的系统。对话管理器会处理余额并使用 NLG 系统将余额转换为适当的句子。请注意,这些系统中有些是基于规则的系统,而另一些则使用端到端深度学习。问答系统就是端到端深度学习系统的一个例子,在这种系统中,对话管理和自然语言理解(NLU)是一个整体。

会话式人工智能应用有多种类型,其中最常见的包括:

  • 任务导向或槽填充系统

  • 问答系统

  • 机器阅读理解

  • 社交或闲聊聊天机器人

以下各节将描述这些类型。

任务导向或槽填充系统

任务导向系统是为了完成特定任务而专门构建的。任务的一些例子包括订披萨、获取银行账户的最新余额、打电话、发送短信、开灯等。大多数虚拟助手所暴露的功能可以归类为此类。一旦确定了用户的意图,控制权就会转交给管理特定意图的模型,收集完成任务所需的所有信息并与用户管理对话。命名实体识别(NER)和词性标注(POS)检测模型在这样的系统中扮演着至关重要的角色。假设用户需要填写一个包含一些信息的表单,而机器人与用户进行互动以获取完成任务所需的信息。我们以订披萨为例。下表展示了这个过程中的简化选择示例:

大小 底边 配料 外卖 数量
小中大 XL 薄底常规深盘无麸质 芝士墨西哥辣椒菠萝意大利辣香肠 外卖送货 12…

这里是一个与机器人对话的虚构示例:

图形用户界面、文本、应用程序、聊天或短信  描述自动生成

图 9.3:一个可能的披萨订购机器人对话示例

机器人跟踪所需信息,并随着对话的进展不断标记其已收到的信息。一旦机器人收集到完成任务所需的所有信息,它就可以执行任务。注意,为了简洁起见,一些步骤(如确认订单或客户要求披萨配料选项)已被省略。

在今天的世界中,像 Dialogflow(Google Cloud 的一部分)和 LUIS(Azure 的一部分)这样的解决方案简化了构建此类对话代理的过程,只需配置即可。让我们看看如何使用 Dialogflow 实现一个简单的机器人,完成上述披萨订购任务的一部分。注意,为了简化配置并使用 Dialogflow 的免费层,示例保持简小。第一步是访问 cloud.google.com/dialogflow,这是该服务的主页。Dialogflow 有两个版本——Essentials 或 ES 版和 CX 版。CX 是高级版,具有更多功能和控制。Essentials 是简化版,提供免费层,非常适合构建机器人的试用版。向下滚动页面,直到看到 Dialogflow Essentials 部分,并点击 进入控制台 链接,如下图 图 9.4 所示:

一张包含图形用户界面的图片  描述自动生成

图 9.4:Dialogflow 控制台访问

点击控制台可能需要服务授权,您可能需要使用您的 Google Cloud 账户登录。或者,您可以导航到 dialogflow.cloud.google.com/#/agents 查看已配置的代理列表。此屏幕如图 9.5所示:

图形用户界面,应用程序,Teams,描述自动生成

图 9.5:Dialogflow 中的代理配置

可以通过点击右上角的蓝色创建代理按钮来创建一个新代理。如果您看到不同的界面,请确保您正在使用 Dialogflow Essentials。您也可以使用以下 URL 进入代理部分:dialogflow.cloud.google.com/#/agents。这将打开新的代理配置屏幕,如图 9.6所示:

图形用户界面,文本,应用程序,Teams,描述自动生成

图 9.6:创建新代理

请注意,这不是 Dialogflow 的全面教程,因此我们将使用几个默认值来说明构建插槽填充机器人(slot-filling bot)的概念。点击创建将构建一个新机器人并加载屏幕,如图 9.7所示。构建机器人的主要部分是定义意图。我们机器人的主要意图是订购披萨。在创建意图之前,我们将配置几个实体:

图形用户界面,文本,应用程序,描述自动生成

图 9.7:一个准备配置的简化代理

这些实体是机器人在与用户对话时将填写的插槽。在此案例中,我们将定义两个实体——披萨底座和披萨大小。点击左侧“实体”(Entities)旁边的+符号,您将看到以下屏幕:

图形用户界面,应用程序,描述自动生成

图 9.8:在 Dialogflow 中配置披萨底座实体的选项

左侧的值表示披萨底座(crust)实体的值,右侧的多个选项或同义词是用户可以输入或说出的与每个选择对应的术语。我们将配置四个选项,分别对应上表中的内容。另一个实体将用于配置披萨的大小。配置后的实体如图 9.9所示:

图形用户界面,文本,应用程序,电子邮件,描述自动生成

图 9.9:大小实体的配置

现在我们可以开始构建意图了。点击左侧导航栏中意图部分旁边的+号。我们将这个意图命名为order,因为这个意图将从用户那里获取外壳和尺寸选项。首先,我们需要指定一组训练短语,这些短语将触发这个意图。一些这样的训练短语示例如“我想点披萨”或“我能要一份披萨吗?”。图 9.10显示了一些为该意图配置的训练短语:

图形用户界面,文本,应用程序,电子邮件 描述自动生成

图 9.10:触发订单意图的训练短语

这张图中隐藏了大量的机器学习和深度学习,Dialogflow 对其进行了简化。例如,*台可以处理文本输入以及语音输入。这些训练示例是具有代表性的,实际的表达方式不需要与这些表达直接匹配。

下一步是定义我们从用户那里需要的参数。我们添加一个包含两个参数的操作——尺寸和外壳。请注意,实体列将参数与定义的实体及其值相关联。列定义了一个变量名称,可在未来的对话或与 API 集成时使用:

图形用户界面,应用程序,表格 描述自动生成

图 9.11:订单意图所需的参数

对于每个参数,我们需要指定一些代理将用来询问用户信息的提示语。下面的图 9.12显示了一些尺寸参数的示例提示。您可以选择为提示配置自己的表达方式:

图形用户界面,文本,应用程序,电子邮件 描述自动生成

图 9.12:尺寸参数的提示选项

配置意图的最后一步是收集信息后配置响应。该配置在响应部分完成,如图 9.13所示:

图形用户界面,应用程序,Teams 描述自动生成

图 9.13:订单意图的响应配置

注意响应文本中使用了$size.original$crust.original。它使用用户在下单时使用的原始术语,在重复订单时将其复述。最后,注意我们将这个意图设置为对话的结束,因为我们已经获取了所需的所有信息。我们的机器人已经准备好进行训练和测试。在配置完训练短语、动作和参数,以及响应后,请点击页面顶部的蓝色保存按钮。页面底部还有一个名为"fulfilment"的部分,允许将意图与 Web 服务连接以完成该意图。可以通过右侧进行机器人测试。请注意,尽管我们仅配置了文本,Dialogflow 同时支持文本和语音界面。这里我们演示的是文本界面,鼓励你也尝试语音界面:

图 9.14:显示响应处理和变量设置的对话示例

基于云的解决方案使得为一般用途构建面向任务的对话代理变得相当容易。然而,为了医疗等特定领域构建代理可能需要定制化的构建。让我们来看一下构建这种系统特定部分的选项:

  • 意图识别:识别意图的最简单方法是将其视为一个分类问题。给定一个发声或输入文本,模型需要将其分类为几个意图。可以使用标准的基于 RNN 的架构,像前面章节中提到的那些,来适应这一任务。

  • 槽标记:标记句子中使用的槽,以对应输入,可以将其视为一个序列分类问题。这与第二章中用于标记文本序列中命名实体的方法类似。双向 RNN 模型在这个部分非常有效。

这些部分可以开发不同的模型,也可以结合在一个端到端的模型中,并配备对话管理器。可以使用专家生成的一组规则,或使用 CRFs(详见第二章使用 BiLSTMs 理解自然语言中的情感)来构建对话状态跟踪系统。最*的研究方法包括 Mrkšić等人在 2017 年提出的神经信念跟踪器,该方法在他们的论文《Neural Belief Tracker: Data-Driven Dialogue State Tracking》中有所介绍。该系统需要三个输入:

  1. 最后的系统输出

  2. 最后的用户发言

  3. 一个来自可能的候选槽的槽值对

这三种输入通过内容模型和语义解码模型进行组合,并输入到二分类决策(softmax)层,以生成最终输出。深度强化学习正被用来优化整体对话策略。

在自然语言生成(NLG)部分,最常见的方法是定义一组可以动态填充的模板。这个方法在前面的图示图 9.13中展示过。神经网络方法,如 Wen 等人在 2015 年发表的论文基于语义控制的 LSTM 自然语言生成用于对话系统中提出的语义控制 LSTM,正在积极研究中。

现在,让我们进入另一个有趣的对话代理领域——问答和机器阅读理解。

问答和机器阅读理解(MRC)对话代理

聊天机器人可以通过基于知识库KB)中包含的信息进行训练,来回答问题。这种设置称为问答设置。另一个相关领域是机器阅读理解MRC。在 MRC 中,问题需要根据与查询一同提供的一组段落或文档进行回答。这两个领域都正看到大量的初创企业活动和创新。通过这两种类型的对话代理,可以实现大量商业应用场景。例如,将财务报告交给机器人,回答诸如“根据财务报告,收入增长多少”的问题,就是 MRC 的一个例子。组织拥有大量的数字化信息库,每天都有新信息涌入。构建这样的代理可以帮助知识工作者快速处理和解析大量信息。像 Pryon 这样的初创公司正在提供对话式 AI 代理,这些代理融合、摄取并适应各种结构化和非结构化数据,形成统一的知识领域,终端用户可以通过自然语言提问的方式来发现信息。

知识库(KB)通常由主体-谓词-宾语三元组组成。主体和宾语是实体,而谓词表示它们之间的关系。KB 可以表示为知识图谱,其中主体和宾语是节点,通过谓词边连接。一个大的挑战是如何在实际中维护这样的知识库和图谱。大多数深度 NLP 方法集中于判断给定的主体-谓词-宾语三元组是否为真。通过这种重新表述,问题被简化为二分类问题。有几种方法,包括使用 BERT 模型,这些方法可以解决分类问题。这里的关键是学习知识库的嵌入,然后在此嵌入之上构建查询。Dat Nguyen 的调查论文实体和关系嵌入模型用于知识图谱补全的综述提供了各种主题的精彩概述,可以深入了解。接下来,我们将专注于 MRC 这一部分内容。

MRC(机器阅读理解)是一个具有挑战性的任务,其目标是回答关于给定一组段落或文档的任何问题。这些段落在预先并不已知,且长度可能不同。最常用的评估模型的研究数据集是斯坦福问答数据集,也就是通常所说的SQuAD。该数据集包含了 10 万个问题,涉及不同的维基百科文章。模型的目标是输出文章中回答问题的文本跨度。微软基于必应查询发布了一个更具挑战性的数据集,称为机器阅读理解(MAchine Reading COmprehension)数据集,或简称MARCO。该数据集包含超过 100 万个匿名问题,涵盖超过 880 万个段落,提取自 350 多万个文档。该数据集中的一些问题可能无法根据段落内容找到答案,而 SQuAD 数据集则没有此问题,这使得 MARCO 成为一个具有挑战性的数据集。与 SQuAD 相比,MARCO 的第二个挑战性在于它要求从多个段落中合成信息来生成答案,而 SQuAD 则要求标注给定段落中的文本跨度。

BERT 及其变体,例如在 2020 年 ICLR 上发布的ALBERT:一种轻量级 BERT 用于自监督学习语言表示,构成了今天大多数竞争性基准的基础。BERT 架构非常适合这个任务,因为它允许传入由[SEP]标记分隔的两段输入文本。BERT 论文评估了他们的语言模型在多个任务上的表现,包括在 SQuAD 任务上的表现。问题标记形成对中的第一部分,段落/文档形成第二部分。输出标记对应第二部分,即段落,根据是否表示跨度的起始或结束,给出评分。

架构的高级图示见于图 9.15

图 9.15:BERT 微调方法用于 SQuAD 问答

问答的一个多模态方面是视觉问答(Visual QA),在第七章多模态网络与图像描述(使用 ResNets 和 Transformer)中简要介绍过。与图像描述所提议的架构类似,能够处理图像和文本标记的架构被用于解决这一挑战。

上述 QA 设置称为单轮对话,因为用户提出一个问题,并且提供了一个需要回答问题的段落。然而,人们的对话是来回交替的。这种设置称为多轮对话。后续问题可能包含先前问题或答案的上下文。多轮对话中的一个挑战是指代消解。考虑以下对话:

用户:您能告诉我账户#XYZ 中的余额吗?

机器人:您的余额是$NNN。

用户:您能从那个账户转账$MM 到账户#ABC 吗?

第二个指令中的“that”指的是账户 #XYZ,这是在提问者的第一个问题中提到的。这称为共指解析。在多轮对话中,解析共指可能会根据引用之间的距离变得相当复杂。在这一领域,关于一般对话机器人已经取得了若干进展,接下来我们将讨论这一部分内容。

一般对话代理

Seq2seq 模型为学习多轮一般对话提供了最佳的灵感。一个有用的思维模型是机器翻译模型。与机器翻译问题类似,对前一个问题的回答可以被看作是将该输入翻译成另一种语言——即回答。通过传入前几轮对话的滑动窗口,而不仅仅是上一轮问题/陈述,可以将更多的上下文编码到对话中。术语“开放领域”常用来描述这一领域的机器人,因为对话的领域不是固定的。机器人应该能够讨论各种话题。这个领域有几个问题是独立的研究课题。

缺乏个性或过于*淡是其中一个问题。对话非常干涩。举个例子,我们在前几章中见过使用温度超参数来调整响应可预测性的方法。由于对话中的不具体性,对话代理有很大的倾向生成“我不知道”这样的回答。可以使用包括 GAN 在内的多种技术来解决这个问题。Facebook 的张等人撰写的 Personalizing Dialogue Agents 论文概述了用来解决这一问题的一些方法。

最*有两个例子突出了类人评论生成的最新技术,分别来自 Google 和 Facebook。Google 发布了一篇名为 Towards a Human-like Open-Domain Chatbot 的论文,介绍了一个名为 Meena 的聊天机器人,拥有超过 26 亿个参数。核心模型是一个 seq2seq 模型,采用 演化 Transformer (ET) 块进行编码和解码。模型架构中,编码器有一个 ET 块,解码器有 13 个 ET 块。ET 块是通过对 Transformer 架构进行 神经架构搜索 (NAS) 发现的。该论文提出了一种新的人工评估指标,称为 合理性与特异性*均值 (**SSA*)。目前文献中提出了多种评估开放领域聊天机器人的不同指标,但缺乏统一的标准。

Facebook 在ai.facebook.com/blog/state-of-the-art-open-source-chatbot/上描述了另一个开放域聊天机器人的例子。本文基于多年的研究,结合了个性化、同理心和知识库(KBs)方面的工作,形成了一个名为 BlenderBot 的混合模型。与谷歌的研究类似,使用了不同的数据集和基准来训练这个聊天机器人。该机器人的代码已分享在parl.ai/projects/recipes/。由 Facebook 研究团队开发的 ParlAI 通过github.com/facebookresearch/ParlAI提供了多个聊天机器人模型。

这是一个非常热门的研究领域,很多工作正在其中展开。要全面覆盖这个话题将需要一本独立的书。希望你通过本书学到了许多可以结合使用的技术,来构建出令人惊艳的对话式智能体。让我们总结一下。

总结

我们讨论了各种类型的对话式智能体,如任务导向型、问答型、机器阅读理解型和一般闲聊型机器人。构建一个对话式 AI 系统是一项非常具有挑战性的任务,涉及许多层面,并且是一个活跃的研究和开发领域。本书前面讨论的内容也有助于构建聊天机器人的各个部分。

结语

首先,恭喜你读完了本书。我希望本书能帮助你打下坚实的高级 NLP 模型基础。这类书籍面临的主要挑战是,它可能在印刷出版时就已经过时。关键在于,新发展是基于过去的进展;例如,Evolved Transformer 基于 Transformer 架构。了解本书中呈现的所有模型将为你提供坚实的基础,并显著减少你理解新发展所需的时间。每一章的有影响力和重要的论文也已在 GitHub 仓库中提供。我期待看到你接下来会发现和构建什么!

第十章:代码安装和设置说明

本章提供了为书中代码设置环境的说明。这些说明:

  • 已在 macOS 10.15 和 Ubuntu 18.04.3 LTS 上进行测试。你可能需要将这些说明转换为 Windows 版本。

  • 仅覆盖 TensorFlow 的 CPU 版本。有关最新的 GPU 安装说明,请参考 www.tensorflow.org/install/gpu。请注意,强烈推荐使用 GPU,它将把复杂模型的训练时间从几天缩短到几个小时。

安装使用了 Anaconda 和 pip。假设你的机器上已经设置并准备好使用 Anaconda。注意,我们使用了一些新的和不常见的包,这些包可能无法通过 conda 安装。在这种情况下,我们将使用 pip

说明

  • 在 macOS 上:conda 49.2,pip 20.3.1

  • 在 Ubuntu 上:conda 4.6.11,pip 20.0.2

GitHub 位置

本书的代码位于以下公共 GitHub 仓库:

github.com/PacktPublishing/Advanced-Natural-Language-Processing-with-TensorFlow-2

请克隆此仓库以访问书中的所有代码。请注意,每一章的经典论文都包含在 GitHub 仓库中的相应章节目录里。

现在,设置 conda 环境的常规步骤如下所述:

  • 步骤 1:创建一个新的 conda 环境,使用 Python 3.7.5:

    $ conda create -n tf24nlp python==3.7.5 
    

    环境名为 tf24nlp,但你可以自由使用自己命名,并确保在接下来的步骤中使用该名称。我喜欢在我的环境名称前加上正在使用的 TensorFlow 版本,并且如果该环境包含 GPU 版本的库,我会在名称后加上“g”。正如你可能推测的那样,我们将使用 TensorFlow 2.4。

  • 步骤 2:激活环境并安装以下软件包:

    $ conda activate tf24nlp
    (tf24nlp) $  conda install pandas==1.0.1 numpy==1.18.1 
    

    这会在我们新创建的环境中安装 NumPy 和 pandas 库。

  • 步骤 3:安装 TensorFlow 2.4。为此,我们需要使用 pip。截至写作时,TensorFlow 的 conda 发行版仍为 2.0。TensorFlow 发展非常迅速。通常,conda 发行版会稍微滞后于最新版本:

    (tf24nlp) $ pip install tensorflow==2.4 
    

    请注意,这些说明适用于 TensorFlow 的 CPU 版本。如需 GPU 安装说明,请参考 www.tensorflow.org/install/gpu

  • 步骤 4:安装 Jupyter Notebook —— 可以自由安装最新版本:

    (tf24nlp) $ conda install Jupyter 
    

    剩余的安装说明涉及特定章节中使用的库。如果你在 Jupyter Notebook 中安装时遇到问题,可以通过命令行安装。

每个章节的具体安装说明如下所示。

第一章安装说明

本章不需要特定的安装说明,因为本章的代码将在 Google Colab 上运行,网址是colab.research.google.com

第二章安装说明

需要安装tfds包:

(tf24nlp) $ pip install tensorflow_datasets==3.2.1 

在接下来的大多数章节中,我们将使用tfds

第三章安装说明

  1. 通过以下命令安装matplotlib

    (tf24nlp) $ conda install matplotlib==3.1.3 
    

    新版本也可能可行。

  2. 安装用于维特比解码的 TensorFlow Addons 包:

    (tf24nlp) $ pip install tensorflow_addons==0.11.2 
    

    请注意,此包无法通过conda获取。

第四章安装说明

本章需要安装sklearn

(tf24nlp) $ conda install scikit-learn==0.23.1 

还需要安装 Hugging Face 的 Transformers 库:

(tf24nlp) $ pip install transformers==3.0.2 

第五章安装说明

无需其他安装。

第六章安装说明

需要安装一个用于计算 ROUGE 分数的库:

(tf24nlp) $ pip install rouge_score 

第七章安装说明

我们需要 Pillow 库来处理图像。该库是 Python Imaging Library 的友好版。可以通过以下命令安装:

(tf24nlp) conda install pillow==7.2.0 

TQDM 是一个很好的工具,可以在执行长时间循环时显示进度条:

(tf24nlp) $ conda install tqdm==4.47.0 

第八章安装说明

需要安装 Snorkel。写本文时,安装的 Snorkel 版本为 0.9.5。请注意,这个版本的 Snorkel 使用了旧版本的 pandas 和 TensorBoard。为了本书中的代码,您应该能够安全地忽略关于版本不匹配的警告。但是,如果您的环境中继续出现冲突,建议创建一个专门的 Snorkel conda环境。

在该环境中运行标注函数,并将输出存储为单独的 CSV 文件。TensorFlow 训练可以通过切换回tf24nlp环境并加载标注数据来运行:

(tf24nlp) $ pip install snorkel==0.9.5 

我们还将使用 BeautifulSoup 来解析 HTML 标签:

(tf24nlp) $ conda install beautifulsoup4==4.9 

本章有一个可选部分涉及绘制词云。此部分需要安装以下包:

(tf24nlp) $ pip install wordcloud==1.8 

请注意,本章还使用了 NLTK,这是我们在第一章中安装的。

第九章安装说明

无。

分享您的经验感谢您抽出时间阅读本书。如果您喜欢本书,请帮助其他人找到它。在www.amazon.com/dp/1800200935上留下评论。

第十一章:你可能感兴趣的其他书籍

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

使用 TensorFlow 2 和 Keras 深度学习 - 第二版 安东尼奥·古利 阿米塔·卡普尔 苏吉特·帕尔

ISBN: 978-1-83882-341-2

  • 使用 TensorFlow 2 和 Keras API 构建机器学习和深度学习系统

  • 使用回归分析,这是最流行的机器学习方法之一

  • 理解 ConvNets(卷积神经网络)及其在图像分类等深度学习系统中的重要性

  • 使用 GAN(生成对抗网络)生成与现有模式相符的新数据

  • 发现 RNN(循环神经网络),它能够智能地处理输入序列,利用序列的一部分来正确解释另一部分

  • 将深度学习应用于自然语言,并解释自然语言文本以生成适当的回应

  • 在云端训练你的模型,并将 TF 应用于真实环境中

  • 探索 Google 工具如何自动化简单的机器学习工作流,而无需复杂的建模

自然语言处理中的 Transformers 丹尼斯·罗斯曼

ISBN: 978-1-80056-579-1

  • 使用最新的预训练 Transformer 模型

  • 掌握原始 Transformer、GPT-2、BERT、T5 和其他 Transformer 模型的工作原理

  • 使用能够超越传统深度学习模型的概念,创建语言理解的 Python 程序

  • 使用多种 NLP *台,包括 Hugging Face、Trax 和 AllenNLP

  • 将 Python、TensorFlow 和 Keras 程序应用于情感分析、文本摘要、语音识别、机器翻译等任务

  • 衡量关键 Transformer 在生产中的生产力,定义它们的范围、潜力和局限性

posted @ 2025-07-08 21:22  绝不原创的飞龙  阅读(127)  评论(0)    收藏  举报