ms-spacy-2e-merge-1

56 阅读1小时+

精通 Spacy(二)

原文:annas-archive.org/md5/cab19785f4c5c60f7cc1edeb81e591d8

译者:飞龙

协议:CC BY-NC-SA 4.0

第五章:使用 spaCy 管道提取语义表示

在本章中,我们将把在第四章中学到的知识应用到航空公司旅行信息系统ATIS)这个知名机票预订系统数据集上。数据包括用户询问信息的句子。首先,我们将提取命名实体,使用SpanRuler创建自己的提取模式。然后,我们将使用DepedencyMatcher模式确定用户话语的意图。我们还将使用代码提取意图,并创建自己的自定义 spaCy 组件,使用Language.pipe()方法以更快的速度处理大型数据集。

在本章中,我们将涵盖以下主要主题:

  • 使用SpanRuler提取命名实体

  • 使用DependencyMatcher提取依存关系

  • 使用扩展属性创建管道组件

  • 使用大数据集运行管道

技术要求

在本章中,我们将处理一个数据集。数据集和本章代码可以在github.com/PacktPublishing/Mastering-spaCy-Second-Edition找到。

我们将使用pandas库来操作我们的数据集。我们还将使用wget命令行工具。pandas可以通过pip安装,而wget在许多 Linux 发行版中是预安装的。

使用SpanRuler提取命名实体

在许多自然语言处理应用中,包括语义解析,我们通过检查实体类型并将实体提取组件放入我们的 NLP 管道中来开始寻找文本中的意义。命名实体在理解用户文本的意义中起着关键作用。

我们还将通过从我们的语料库中提取命名实体来启动语义解析管道。为了了解我们想要提取哪种类型的实体,首先,我们将了解 ATIS 数据集。

了解 ATIS 数据集

在本章中,我们将使用 ATIS 语料库。ATIS 是一个知名的数据集;它是意图分类的标准基准数据集之一。该数据集包括想要预订航班和/或获取航班信息(包括航班费用、目的地和时间表)的客户的话语。

无论 NLP 任务是什么,你都应该用肉眼仔细检查你的语料库。我们想要了解我们的语料库,以便将我们对语料库的观察整合到我们的代码中。在查看我们的文本数据时,我们通常会关注以下方面:

  • 有哪些类型的语句?是短文本语料库,还是语料库由长文档或中等长度的段落组成?

  • 语料库包括哪些类型的实体?例如,包括人名、城市名、国家名、组织名等。我们想要提取哪些?

  • 标点符号是如何使用的?文本是否正确使用了标点,或者根本就没有使用标点?

  • 用户是否遵循了语法规则?语法规则是如何遵循的?大写是否正确?是否有拼写错误?

在开始任何处理之前,我们将检查我们的语料库。以下是方法:

  1. 让我们继续下载数据集:

    mkdir data
    wget -P data https://github.com/PacktPublishing/Mastering-spaCy-Second-Edition/blob/main/chapter_05/data/atis_intents.csv
    

    数据集是一个两列的 CSV 文件。

重要提示

如果你在一个 Jupyter 笔记本上运行代码,你可以在命令前添加一个 ! 来在那里运行它们。

  1. 接下来,我们将使用 pandas 对数据集统计进行一些洞察。pandas 是一个流行的数据处理库,常被数据科学家使用。你可以在 pandas.pydata.org/docs/getting_started/intro_tutorials/ 上了解更多信息。

    import pandas as pd
    df = pd.read_csv("data/atis_intents.csv", header=None, 
                     names=["utterance", "text"])
    df.shape
    

    shape 属性返回一个表示 DataFrame 维度的元组。我们可以看到数据集有两列和 4,978 行。

  2. 让我们创建一个条形图来查看数据集中的出口数量:

    df["utterance"].value_counts().plot.barh()
    

    value_counts() 方法返回一个包含唯一值计数的序列。pandas 库在底层使用 Matplotlib 来绘制条形图;这是结果:

图 5.1 – 出口频率条形图

图 5.1 – 出口频率条形图

大多数用户请求是关于航班的信息,其次是关于机票的请求。然而,在提取出口之前,我们将学习如何提取命名实体。让我们在下一节中这样做。

定义位置实体

在本节中,我们的目标是提取 位置 实体。en_core_web_sm 模型的管道已经有一个 NER 组件。让我们看看默认 NER 模型从 "i want to fly from boston at 838 am and arrive in denver at 1110 in the morning" 这句话中提取了哪些实体:

  1. 首先,我们导入库:

    import spacy
    from spacy import displacy
    
  2. 然后我们加载 spaCy 模型并处理一个句子:

    nlp = spacy.load("en_core_web_sm")
    text = "i want to fly from boston at 838 am and arrive in denver at 1110 in the morning"
    doc = nlp(text)
    
  3. 最后,我们使用 displacy 显示实体:

    displacy.render(doc, style='ent')
    

    我们可以在 图 5.2 中看到结果:

图 5.2 – NER 组件提取的实体

图 5.2 – NER 组件提取的实体

NER 模型为 bostondenver 找到 **全球政治实体 ( **GPE ),但仅知道这些城市是不够的。我们想知道他们想从哪里飞以及飞往何处。这意味着在这种情况下,介词(一个包括介词和后置介词的通用术语)很重要。spaCy 使用通用的 **词性 ( **POS ) 标签,因此介词标签被命名为 "ADP"。你可以在词汇表中看到所有 POS 标签、依存标签或 spaCy 实体类型的描述(github.com/explosion/spaCy/blob/master/spacy/glossary.py)。

回到之前的句子示例,从波士顿在丹佛是我们想要提取的实体。由于我们知道需要哪些 POS 标签和 GPE 实体来创建新的实体,因此实现这种提取的一个好方法就是依赖于 NLP 管道中的TaggerEntityRecognizer组件。我们将通过创建基于标签的规则来提取标记。spaCy 使用SpanRuler组件使得这一过程变得简单易行。

将 SpanRuler 组件添加到我们的处理管道中

使用 spaCy 定制 NLP 管道非常简单。每个管道都是通过 spaCy 组件的组合创建的。一开始可能不太清楚,但当我们加载现成的 spaCy 模型时,它已经包含了几种不同的组件:

nlp.pipe_names

nlp.pipe_names属性按顺序返回组件名称。图 5.3显示了所有这些组件。

图 5.3 – en_core_web_sm 模型的默认组件

图 5.3 – en_core_web_sm 模型的默认组件

我们可以看到,en_core_web_sm模型默认包含['tok2vec', 'tagger', 'parser', 'attribute_ruler', 'lemmatizer', 'ner']组件。每个组件返回一个处理后的Doc对象,然后传递给下一个组件。

您可以使用Language.add_pipe()方法向处理管道添加组件(这里 nlp 是 Language 类的对象,我们将使用它来调用add_pipe())。该方法期望一个包含组件名称的字符串。在底层,此方法负责创建组件,将其添加到管道中,然后返回组件对象。

SpanRuler组件是一个现成的基于规则的跨度命名实体识别组件。该组件允许您将跨度添加到Doc.spans和/或Doc.ents。让我们第一次尝试使用它:

  1. 首先,我们调用add_pipe()方法:

    spanruler_component = nlp.add_pipe("span_ruler")
    
  2. 然后我们使用add_patterns()方法添加模式。它们应该使用包含"label""pattern"键的字典列表来定义:

    patterns_location_spanruler = [
        {"label": "LOCATION", 
         "pattern": [{"POS": "ADP"}, {"ENT_TYPE": "GPE"}]}]
    spanruler_component.add_patterns(patterns_location_spanruler)
    

    spaCy 使用Thincthinc.ai/docs/api-config#registry)注册表,这是一个将字符串键映射到函数的系统。"span_ruler"字符串名称是引用SpanRuler组件的字符串。然后我们定义一个名为LOCATION的模式,并使用add_patterns()方法将其添加到组件中。

  3. doc.ents不同,doc.spans允许重叠匹配。默认情况下,SpanRuler将匹配项作为spans添加到doc.spans["ruler"]跨度组中。让我们再次处理文本并检查SpanRuler是否完成了其工作。由于组件将 Span 添加到"ruler"键,我们需要指定这一点以使用displacy渲染跨度:

    doc = nlp(text)
    options = {"spans_key": "ruler"}
    displacy.render(doc, style='span', options=options)
    

    让我们看看图 5.4的结果。

图 5.4 – 使用 SpanRuler 提取的跨度

图 5.4 – 使用 SpanRuler 提取的跨度

我们可以看到,组件识别了"from boston""in denver"跨度。SpanRuler有一些您可以更改的设置。这可以通过nlp.pipe()方法上的 config 参数或使用config.cfg文件来完成。让我们将跨度添加到Doc.ents而不是doc.spans["ruler"]

  1. 首先,我们移除了管道中的组件,因为我们只有一个具有相同名称的组件:

    nlp.remove_pipe("span_ruler")
    
  2. 然后我们将组件的"annotate_ents"参数设置为True。由于我们的模式需要EntityRecognizer添加的实体,因此我们还需要将覆盖参数设置为False,这样我们就不覆盖它们:

    config = {"annotate_ents": True, "overwrite": False}
    
  3. 现在我们使用此配置再次创建组件,添加之前创建的模式,并再次处理文本。

    spanruler_component_v2 = nlp.add_pipe(
        "span_ruler", config=config)
    spanruler_component_v2.add_patterns(patterns_location_spanruler)
    doc = nlp(text)
    displacy.render(doc, style='ent')
    

    通过做所有这些,匹配项变成了实体,而不是跨度。

    让我们看看图 5.5 中的新实体:

图 5.5 – 使用 SpanRuler 提取的实体

图 5.5 – 使用 SpanRuler 提取的实体

您可以看到,{'GPE', 'boston'}{'GPE', 'denver'}实体不再存在;现在分别是{'from boston', 'LOCATION'}{'in denver', 'LOCATION'}Doc.ents中不允许重叠实体,因此默认使用util.filter_spans函数进行过滤。此函数保留较短的跨度中第一个最长的跨度。

您可以覆盖大多数SpanRuler设置。一些可用的设置如下:

  • spans_filter:一个在将跨度分配给doc.spans之前过滤跨度的方法

  • ents_filter:一个在将跨度分配给doc.ents之前过滤跨度的方法

  • validate:一个设置是否应该验证模式或将其作为验证传递给MatcherPhraseMatcher的方法

在本节中,我们学习了如何使用SpanRuler创建和提取实体。有了提取位置实体的模式,我们现在可以继续提取话语的意图。让我们在下一节中使用DependencyMatcher来完成这项工作。

使用 DependencyMatcher 提取依赖关系

为了提取话语的意图,我们需要根据它们之间的语法关系匹配标记。目标是找出用户携带的意图类型——预订航班、在他们已预订的航班上购买餐点、取消航班等。每个意图都包括一个动词(预订)和网页所作用的对象(航班、酒店、餐点等)。

在本节中,我们将从话语中提取及物动词及其直接宾语。我们将通过提取及物动词及其直接宾语来开始我们的意图识别部分。在我们继续提取及物动词及其直接宾语之前,让我们首先快速回顾一下及物动词和直接/间接宾语的概念。

语言学入门

让我们探索一些与句子结构相关的语言概念,包括动词和动词宾语关系。动词是句子中非常重要的组成部分,因为它表示句子中的动作。句子的宾语是受到动词动作影响的事物或人。因此,句子动词和宾语之间存在自然联系。及物性的概念捕捉了动词宾语关系。及物动词是需要作用对象的动词。让我们看看一些例子:

I bought flowers.
He loved his cat.
He borrowed my book.

在这些例子句子中,boughtlovedborrowed是及物动词。在第一个句子中,bought是及物动词,flowers是其宾语,即句子主语所购买的事物。I Lovedhis catborrowedmy book是及物动词宾语例子。我们再次关注第一个句子——如果我们删除flowers宾语会发生什么?让我们看看这里的情况:

I bought

你买了什么?如果没有宾语,这个句子就完全没有意义了。在前面的句子中,每个宾语都完成了动词的意义。这是理解一个动词是否及物的一种方法——删除宾语并检查句子是否在语义上仍然完整。

有些动词是及物的,有些动词是不及物的。不及物动词是及物动词的相反,它不需要作用对象。让我们看看一些例子:

Yesterday I slept for 8 hours.
The cat ran towards me.
When I went out, the sun was shining.
Her cat died 3 days ago.

在所有前面的句子中,即使没有宾语,动词也有意义。如果我们删除除了主语和宾语之外的所有单词,这些句子仍然是有意义的:

I slept.
The cat ran.
The sun was shining.
Her cat died.

将不及物动词与宾语搭配是没有意义的。你不能跑某人或某物,你不能使某人或某物发光,你当然也不能使某人或某物死亡。

句子宾语

正如我们之前提到的,宾语是受到动词动作影响的物体或人。动词所陈述的动作是由句子主语执行的,而句子宾语受到动作的影响。一个句子可以是直接的或间接的。直接宾语回答了什么的问题。你可以通过问**主语{动词}什么/谁?**来找到直接宾语。以下是一些例子:

I bought flowers.       I bought what?      - flowers
He loved his cat.       He loved who?       - his cat
He borrowed my book.    He borrowed what?   - my book

一个间接宾语回答了为了什么为了谁和/或给谁的问题。让我们看看一些例子:

He gave me his book.    He gave his book to whom?   - me
He gave his book to me. He gave his book to whom?   - me

间接宾语通常由介词toforfrom等引导。正如你从这些例子中看到的,间接宾语也是一个宾语,并且受到动词动作的影响,但它在句子中的角色略有不同。间接宾语有时被视为直接宾语的接受者。

这就是您需要了解的关于及物/不及物动词和直接/间接宾语的知识,以便消化本章的内容。如果您想了解更多关于句子语法的知识,可以阅读 Emily Bender 的优秀书籍 自然语言处理的语言学基础 (dl.acm.org/doi/book/10.5555/2534456)。我们已经涵盖了句子语法的基础知识,但这仍然是一个深入了解语法的极好资源。

使用 DependencyMatcher 组件匹配模式

DependencyMatcher 组件使我们能够将模式与提取信息相匹配,但与 SpanRuler 模式定义的相邻标记列表不同,DependencyMatcher 模式匹配指定它们之间关系的标记。该组件与 DependencyParser 组件提取的依存关系一起工作。让我们通过一个示例看看这个组件提取的信息类型:

text = "show me flights from denver to philadelphia on tuesday"
doc = nlp(text)
displacy.render(doc, style='dep')

让我们看看 图 5.6 的结果:

图 5.6 – 句子的依存弧(句子其余部分省略)

图 5.6 – 句子的依存弧(句子其余部分省略)

提取的依存标签是弧线下方的标签。Show 是一个及物动词,在这个句子中,它的直接宾语是 flight。这个依存关系由 DependencyParser 组件提取,并标记为 dobj(直接宾语)。

我们的目标是提取意图,因此我们将定义始终寻找 动词 和其 dobj 依存关系的模式。DependencyMatcher 使用 Semgrex 操作符来定义模式。Semgrex 语法 可能一开始会让人困惑,所以让我们一步一步来。

DependencyMatcher 模式由一系列字典组成。第一个字典使用 RIGHT_IDRIGHT_ATTRS 定义一个锚点标记。RIGHT_ID 是关系右侧节点的唯一名称,RIGHT_ATTRS 是要匹配的标记属性。模式格式与 SpanRuler 中使用的相同模式。在我们的模式中,锚点标记将是 dobj 标记,因此第一个字典定义如下:

pattern = [
    {
        "RIGHT_ID": "direct_object_token",
        "RIGHT_ATTRS": {"DEP": "dobj"}
    }
]

如 spaCy 的文档所述 (spacy.io/usage/rule-based-matching/#dependencymatcher),在第一个字典之后,模式字典应包含以下键:

  • LEFT_ID:关系左侧节点的名称,该名称已在早期节点中定义

  • REL_OP:描述两个节点之间关系的操作符

  • RIGHT_ID:关系右侧节点的唯一名称

  • RIGHT_ATTRS:与 SpanRuler 中提供的正则标记模式相同的格式,用于匹配关系右侧节点的标记属性

给定这些键,我们通过指示关系的左侧节点、为新右侧节点定义一个名称以及指示描述两个节点之间关系的运算符来构建模式。回到我们的例子,在将 direct_object_token 定义为锚点后,我们将下一个字典的 RIGHT_ID 设置为 VERB 标记,并将运算符定义为 direct_object_token < verb_token,因为直接宾语是动词的 直接依赖项。以下是 DependencyMatcher 支持的一些其他运算符(您可以在这里查看完整的运算符列表):

  • A < B : A 是 B 的直接依赖项

  • A > B : A 是 B 的直接头

  • A << B : A 是在 dep → head 路径上跟随 B 的链中的依赖项

  • A >> B : A 是在 head → dep 路径上跟随 B 的链中的头

如果这些操作让您有点头疼,那也发生在我身上。这只是其中的一小部分,您可以在这里查看完整的运算符列表。好吧,让我们回到我们的例子并定义完整的模式:

pattern = [
    {
        "RIGHT_ID": "direct_object_token",
        "RIGHT_ATTRS": {"DEP": "dobj"}
    },
    {
        "LEFT_ID": "direct_object_token",
        "REL_OP": "<",
        "RIGHT_ID": "verb_token",
        "RIGHT_ATTRS": {"POS": "VERB"}
    }
]

现在,我们可以创建 DependencyMatcher

  1. 首先,我们需要传递 vocabulary 对象(词汇与匹配器操作的文档共享):

    from spacy.matcher import DependencyMatcher
    matcher = DependencyMatcher(nlp.vocab)
    

    接下来,我们需要定义一个回调函数,该函数将接受以下参数:matcherdocimatchesmatcher 参数指的是匹配器实例,doc 是正在分析的文档,i 是当前匹配的索引,matches 是一个详细说明找到的匹配项的列表。我们将创建一个 callback 函数来显示一个单词的意图,例如 bookFlightcancelFlightbookMeal 等。该函数将接受匹配项的标记并打印它们的词元:

    def show_intent(matcher, doc, i, matches):
        match_id, token_ids = matches[i]
        verb_token = doc[token_ids[1]]
        dobj_token = doc[token_ids[0]]
        intent = verb_token.lemma_ + dobj_token.lemma_.capitalize()
    print("Intent:", intent)
    
  2. 要向 matcher 添加规则,我们指定一个 ID 键、一个或多个模式以及可选的回调函数来处理匹配项。最后,我们再次处理文本并调用 matcher 对象,将此 doc 作为参数传递:

    matcher.add("INTENT", [pattern], on_match=show_intent)
    doc = nlp("show me flights from denver to philadelphia on tuesday")
    matches = matcher(doc)
    

    太棒了!代码打印出 Intent: showFlightIntent ,所以识别是成功的。在这里,我们识别了一个单一意图,但某些话语可能携带多个意图。例如,考虑以下来自语料库的话语:

    show all flights and fares from denver to san francisco
    

    在这里,用户想要列出所有航班,同时查看票价信息。一种处理方式是将这些意图视为单一且复杂的意图。处理此类话语的常见方式是用多个意图标记话语。

让我们看看 DEP 依赖关系由 DependencyParser 提取的:

图 5.7 – 新句子的依存弧(句子其余部分省略)

图 5.7 – 新句子的依存弧(句子其余部分省略)

图 5.7 中,我们看到 dobj 弧连接了 showflightsconj(连词)弧将 flightsfares 连接起来,表示连词关系。这种关系是通过连词如 andor 构建的,表示一个名词通过这个连词与另一个名词相连。现在让我们编写代码来识别这两个意图:

  1. 将弧关系转换为 REL_OP 操作符,direct_object_token 将成为这次关系的头,因此我们将使用 > 操作符,因为 direct_object_token 是新的 conjunction_token直接头。这是匹配两个意图的新模式:

    pattern_two = [
        {
            "RIGHT_ID": "direct_object_token",
            "RIGHT_ATTRS": {"DEP": "dobj"}
        },
        {
            "LEFT_ID": "direct_object_token",
            "REL_OP": "<",
            "RIGHT_ID": "verb_token",
            "RIGHT_ATTRS": {"POS": "VERB"}
        },
        {
            "LEFT_ID": "direct_object_token",
            "REL_OP": ">",
            "RIGHT_ID": "conjunction_token",
            "RIGHT_ATTRS": {"DEP": "conj"}
        }
    ]
    
  2. 我们还需要更新回调函数,使其能够打印出两个意图:

    def show_two_intents(matcher, doc, i, matches):
        match_id, token_ids = matches[i]
        verb_token = doc[token_ids[1]]
        dobj_token = doc[token_ids[0]]
        conj_token = doc[token_ids[2]]
        intent = verb_token.lemma_ + \
            dobj_token.lemma_.capitalize() + ";" + \
            verb_token.lemma_ + conj_token.lemma_.capitalize()
        print("Two intents:", intent)
    
  3. 现在我们只需要将这个新规则添加到匹配器中。由于模式 ID 已经存在,模式将被扩展:

    matcher.add("TWO_INTENTS", [pattern_two], 
                on_match=show_two_intents)
    
  4. 在设置好所有这些之后,我们现在可以再次找到匹配项:

    doc = nlp("show all flights and fares from denver to san francisco")
    matches = matcher(doc)
    

现在匹配器找到了两个模式的标记,第一个模式和这个新的模式,它匹配两个意图。到目前为止,我们只是 打印意图,但在实际设置中,将此信息存储在 Doc 对象上是一个好主意。为此,我们将创建自己的 spaCy 组件。让我们在下一节学习如何做到这一点。

使用扩展属性创建管道组件

要创建我们的组件,我们将使用 @Language.factory 装饰器。组件工厂是一个可调用对象,它接受设置并返回一个 pipeline component function@Language.factory 装饰器还将自定义组件的名称添加到注册表中,使得可以使用 .add_pipe() 方法将组件添加到管道中。

spaCy 允许你在 DocSpanToken 对象上设置任何自定义属性和方法,这些属性和方法将作为 Doc._.Span._.Token._. 可用。在我们的案例中,我们将向 Doc 添加 Doc._.intent 属性,利用 spaCy 的数据结构来存储我们的数据。

我们将在一个 Python 类内部实现组件逻辑。spaCy 期望 __init__() 方法接受 nlpname 参数(spaCy 会自动填充它们),而 __call__() 方法应该接收并返回 Doc

让我们创建 IntentComponent 类:

  1. 首先,我们创建类。在 __init__() 方法中,我们创建 DependencyMatcher 实例,将模式添加到匹配器中,并设置 intent 扩展属性:

    class IntentComponent:
        def __init__(self, nlp: Language):
            self.matcher = DependencyMatcher(nlp.vocab)
            pattern = [
                {
                    "RIGHT_ID": "direct_object_token",
                    "RIGHT_ATTRS": {"DEP": "dobj"}
                },
                {
                    "LEFT_ID": "direct_object_token",
                    "REL_OP": "<",
                    "RIGHT_ID": "verb_token",
                    "RIGHT_ATTRS": {"POS": "VERB"}
                }
            ]
            pattern_two = [
                {
                    "RIGHT_ID": "direct_object_token",
                    "RIGHT_ATTRS": {"DEP": "dobj"}
                },
                {
                    "LEFT_ID": "direct_object_token",
                    "REL_OP": "<",
                    "RIGHT_ID": "verb_token",
                    "RIGHT_ATTRS": {"POS": "VERB"}
                },
                {
                    "LEFT_ID": "direct_object_token",
                    "REL_OP": ">",
                    "RIGHT_ID": "conjunction_token",
                    "RIGHT_ATTRS": {"DEP": "conj"}
                }
            ]
            self.matcher.add("INTENT", [pattern])
            self.matcher.add("TWO_INTENTS", [pattern_two])
            if not Doc.has_extension("intent"):
                Doc.set_extension("intent", default=None)
    

    现在,在 __call__() 方法内部,我们找到匹配项并检查是否是 "TWO_INTENTS" 匹配。如果是,我们提取该模式的标记并设置 doc._.intent 属性;如果不是,则在 else 块中,我们提取 "INTENT" 匹配的标记:

        def __call__(self, doc: Doc) -> Doc:
            matches = self.matcher(doc)
            for match_id, token_ids in matches:
                string_id = nlp.vocab.strings[match_id]
                if string_id == "TWO_INTENTS":
                    verb_token = doc[token_ids[1]]
                    dobj_token = doc[token_ids[0]]
                    conj_token = doc[token_ids[2]]
                    intent = verb_token.lemma_ + \
                        dobj_token.lemma_.capitalize() + \
                        ";" + verb_token.lemma_ + \
                        conj_token.lemma_.capitalize()
                    doc._.intent = intent
                    break
            else:
                for match_id, token_ids in matches:
                    string_id = nlp.vocab.strings[match_id]
                    if string_id == "INTENT":
                        verb_token = doc[token_ids[1]]
                        dobj_token = doc[token_ids[0]]
                        intent = verb_token.lemma_ + \
                            dobj_token.lemma_.capitalize()
                        doc._.intent = intent
            return doc
    

    使用这段代码,我们在Doc中注册自定义扩展,通过在__call__()方法上设置doc._.intent = intent来找到匹配项并保存意图。

  2. 现在我们有了自定义组件的类,下一步是使用装饰器来注册它:

    @Language.factory("intent_component")
    def create_intent_component(nlp: Language, name: str):
        return IntentComponent(nlp)
    

重要提示

如果你正在使用 Jupyter Notebook 并且需要重新创建组件,你需要重新启动内核。如果不这样做,spaCy 会给我们一个错误,因为组件名称已经被注册。

就这样,这就是你的第一个自定义组件!恭喜!现在,为了提取意图,我们只需要将组件添加到管道中。如果我们想查看意图,我们可以通过doc._.intent来访问它。这是你可以这样做的方式:

nlp.add_pipe("intent_component")
text = "show all flights and fares from denver to san francisco"
doc = nlp(text)
doc._.intent

太酷了,对吧?如果你不记得,数据集有 4,978 个语音。这不是一个非常大的数字,但如果它更大呢?spaCy 能帮助我们让它更快吗?是的!在下一节中,我们将学习如何使用Language.pipe()方法运行我们的管道。

使用大型数据集运行管道

Language.pipe()方法将文本作为流处理,并按顺序产生Doc对象。它以批量而不是逐个缓冲文本,因为这通常更有效率。如果我们想获取特定的文档,我们需要先调用list(),因为该方法返回一个 Python 生成器,它产生Doc对象。这是你可以这样做的方式:

utterance_texts = df.text.to_list()
processed_docs = list(nlp.pipe(utterance_texts))
print(processed_docs[0], processed_docs[0]._.intent)

在前面的代码中,我们正在从本章开头加载的 DataFrame 中获取文本语音列表,并使用.pipe()进行批量处理。让我们通过使用和不使用.pipe()方法来比较时间差异:

import timestart_time = time.time()
utterance_texts = df.text.to_list()
processed_docs_vanilla = [nlp(text) for text in utterance_texts]
end_time = time.time()
execution_time = end_time - start_time
print("Execution time:", execution_time, "seconds")
>>> Execution time: 27.12 seconds

这给了我们 27.12 秒的时间。现在,让我们使用以下方法:

import timestart_time = time.time()
utterance_texts = df.text.to_list()
processed_docs_pipe = list(nlp.pipe(utterance_texts))
end_time = time.time()
execution_time = end_time - start_time
print("Execution time:", execution_time, "seconds")
>>> Execution time: 5.90 seconds

使用nlp.pipe(),我们在 5.90 秒内得到了相同的结果。这是一个巨大的差异。我们还可以指定batch_sizen_process来设置要使用的处理器数量。还有一个选项可以禁用组件,如果你只需要运行.pipe()来获取特定组件处理过的文本结果。

太棒了,我们使用自己的自定义组件完成了我们的第一个管道!恭喜!以下是管道的完整代码:

import spacy
from spacy.language import Language
from spacy.tokens import Doc
from spacy.matcher import DependencyMatcher
@Language.factory("intent_component")
def create_intent_component(nlp: Language, name: str):
@Language.factory("intent_component")
def create_intent_component(nlp: Language, name: str):
    return IntentComponent(nlp)
class IntentComponent:
    def __init__(self, nlp: Language):
                self.matcher = DependencyMatcher(nlp.vocab)
        pattern = [
            {
                "RIGHT_ID": "direct_object_token",
                "RIGHT_ATTRS": {"DEP": "dobj"}
            },
            {
                "LEFT_ID": "direct_object_token",
                "REL_OP": "<",
                "RIGHT_ID": "verb_token",
                "RIGHT_ATTRS": {"POS": "VERB"}
            }
        ]
        pattern_two = [
            {
                "RIGHT_ID": "direct_object_token",
                "RIGHT_ATTRS": {"DEP": "dobj"}
            },
            {
                "LEFT_ID": "direct_object_token",
                "REL_OP": "<",
                "RIGHT_ID": "verb_token",
                "RIGHT_ATTRS": {"POS": "VERB"}
            },
            {
                "LEFT_ID": "direct_object_token",
                "REL_OP": ">",
                "RIGHT_ID": "conjunction_token",
                "RIGHT_ATTRS": {"DEP": "conj"}
            }
        ]
        self.matcher.add("INTENT", [pattern])
        self.matcher.add("TWO_INTENTS", [pattern_two])
        if not Doc.has_extension("intent"):
            Doc.set_extension("intent", default=None)
    def __call__(self, doc: Doc) -> Doc:
        matches = self.matcher(doc)
        for match_id, token_ids in matches:
            string_id = nlp.vocab.strings[match_id]
            if string_id == "TWO_INTENTS":
                verb_token = doc[token_ids[1]]
                dobj_token = doc[token_ids[0]]
                conj_token = doc[token_ids[2]]
                intent = verb_token.lemma_ + \
                    dobj_token.lemma_. capitalize() + ";" + \
                    verb_token.lemma_ + \
                    conj_token.lemma_. capitalize()
                doc._.intent = intent
                break
        else:
            for match_id, token_ids in matches:
                string_id = nlp.vocab.strings[match_id]
                if string_id == "INTENT":
                    verb_token = doc[token_ids[1]]
                    dobj_token = doc[token_ids[0]]
                    intent = verb_token.lemma_ + \
                        dobj_token.lemma_. capitalize()
                    doc._.intent = intent
       return doc
nlp = spacy.load("en_core_web_sm")
nlp.add_pipe("intent_component")
text = "show all flights and fares from denver to san francisco"
doc = nlp(text)
doc._.intent

spaCy 使管道代码整洁有序,这是我们想要维护代码库时两个至关重要的品质。

摘要

在本章中,你学习了如何生成语音的完整语义解析。首先,你添加了一个SpanRuler组件来提取与用例上下文相关的 NER 实体。然后,你学习了如何使用DependencyMatcher通过分析句子结构来进行意图识别。接下来,你还学习了如何创建自己的自定义 spaCy 组件来提取语音的意图。最后,你看到了如何使用Language.pipe()方法更快地处理大型数据集。

SpanRulerDependencyMatcher 都依赖于我们创建的模式。创建这些模式的过程是一个反复迭代的过程。我们分析结果,然后测试新的模式,然后再分析结果,如此循环。本章的目标是教会你如何使用这些工具,以便你可以在自己的项目中执行这个过程。

在接下来的章节中,我们将更多地转向机器学习方法。第六章 将介绍如何使用 spaCy 与 Transformers 结合使用。

第六章:利用 spaCy 与转换器一起工作

转换器是 NLP 中的最新热门话题。本章的目标是学习如何使用转换器来提高 spaCy 中可训练组件的性能。

首先,你将了解转换器和迁移学习。接下来,你将更深入地了解 spaCy 可训练组件以及如何训练一个组件,介绍 spaCy 的 config.cfg 文件和 spaCy 的 CLI。然后,你将了解常用 Transformer 架构的架构细节——双向编码器 Transformer 表示BERT)及其继任者 RoBERTa。最后,你将训练 TextCategorizer 组件,使用转换器层来提高准确率对文本进行分类。

到本章结束时,你将能够准备训练数据并微调你自己的 spaCy 组件。由于 spaCy 的设计方式;在这样做的时候,你将遵循软件工程的最佳实践。你还将对转换器的工作原理有一个坚实的基础,这在我们与 大型 语言模型LLMs)在 第七章 一起工作时将非常有用。你将能够仅用几行代码就利用 Transformer 模型和迁移学习构建最先进的 NLP 管道。

在本章中,我们将涵盖以下主要主题:

  • 使用 spaCy 进行模型训练和迁移学习

  • 使用 spaCy 管道对文本进行分类

  • 与 spaCy config.cfg 文件一起工作

  • 准备训练数据以使用 spaCy 微调模型

  • 使用 Hugging Face 的 Transformer 进行 spaCy 的下游任务

技术要求

数据集和章节代码可以在 github.com/PacktPublishing/Mastering-spaCy-Second-Edition 找到。

我们将使用 pandas 库来操作数据集,并安装 spacy-transformers 库以与 transformer spaCy 组件一起工作。

转换器和迁移学习

2017 年,随着 Vaswani 等人发表的研究论文 Attention Is All You Need 的发布,自然语言处理领域发生了一个里程碑事件(arxiv.org/abs/1706.03762),该论文介绍了一种全新的机器学习思想和架构——转换器。NLP 中的转换器是一个新颖的想法,旨在解决序列建模任务,并针对 长短期记忆LSTM)架构引入的一些问题。以下是论文如何解释转换器的工作原理:

Transformer 是第一个完全依赖自注意力来计算其输入和输出表示的转换模型,不使用序列对齐 RNN 或卷积

在这个上下文中,转换意味着通过将输入单词和句子转换为向量来转换输入到输出。通常,一个变压器在一个巨大的语料库上训练。然后,在我们的下游任务中,我们使用这些向量,因为它们携带有关词语义、句子结构和句子语义的信息。

在变压器之前,NLP 世界中的热门技术是词向量技术。词向量基本上是一个词的密集数字表示。这些向量令人惊讶的地方在于,语义相似的词具有相似的词向量。例如,GloVeFastText向量已经在维基百科语料库上进行了训练,可以用于语义相似度计算。Token.similarity()Span.similarity()Doc.similarity()方法都使用词向量来预测这些容器之间的相似度。这是一个简单的迁移学习示例用法,其中我们使用文本中的知识(从词向量训练中提取的词的知识)来解决新问题(相似度问题)。变压器更强大,因为它们被设计成能够理解上下文中的语言,这是词向量无法做到的。我们将在BERT部分了解更多关于这一点。

Transformer是模型架构的名称,但 Hugging Face Transformers 也是 Hugging Face 提供的一套 API 和工具,用于轻松下载和训练最先进的预训练模型。Hugging Face Transformers 提供了数千个预训练模型,用于执行 NLP 任务,如文本分类、文本摘要、问答、机器翻译以及超过 100 种语言的自然语言生成。目标是让最先进的 NLP 技术对每个人可访问。

在本章中,我们将使用变压器模型来应用一种迁移学习形式,以提高下游任务的准确性——在我们的案例中,是文本分类任务。我们将通过结合使用 spaCy 的transformer组件(来自spacy-transformers包)和textcat组件来实现这一点,以提高管道的准确性。使用 spaCy,还有直接使用现有 Hugging Face 模型的预测选项。为此,你可以使用spacy-huggingface-pipelines包中的封装器。我们将在第十一章中看到如何做到这一点。

好的,现在你已经知道了什么是变压器,让我们继续学习更多关于该技术背后的机器学习概念。

从 LSTMs 到 Transformers

在 Transformer 出现之前,LSTM 神经网络单元是用于建模文本的常用解决方案。LSTM 是循环神经网络RNN)单元的一种变体。RNN 是一种特殊的神经网络架构,可以分步骤处理序列数据。在通常的神经网络中,我们假设所有输入和输出都是相互独立的。问题是这种建模方式并不适用于文本数据。每个单词的存在都依赖于其相邻的单词。例如,在机器翻译任务中,我们通过考虑之前预测的所有单词来预测一个单词。RNN 通过捕获关于过去序列元素的信息并将它们保持在内存中(称为隐藏状态)来解决这种情况。

LSTM 是为了解决 RNN 的一些计算问题而创建的。RNN 有一个问题,就是会忘记序列中的一些数据,以及由于链式乘法导致的梯度消失和爆炸等数值稳定性问题。LSTM 单元比 RNN 单元稍微复杂一些,但计算逻辑是相同的:我们每个时间步输入一个单词,LSTM 在每个时间步输出一个值。

LSTM 比传统的 RNN 更好,但它们也有一些缺点。LSTM 架构有时在学习长文本时会有困难。长文本中的统计依赖关系可能很难用 LSTM 表示,因为随着时间步的推移,LSTM 可能会忘记之前处理的一些单词。此外,LSTM 的本质是顺序的。我们每个时间步处理一个单词。这意味着学习过程的并行化是不可能的;我们必须顺序处理。不允许并行化造成了一个性能瓶颈。

Transformer 通过完全不使用循环层来解决这些问题。Transformer 架构由两部分组成——左侧的输入编码器块(称为编码器)和右侧的输出解码器块(称为解码器)。以下图表来自原始论文《Attention Is All You Need》(arxiv.org/abs/1706.03762),展示了 Transformer 架构:

图 6.1 – 来自论文“Attention is All You Need”的 Transformer 架构

图 6.1 – 来自论文“Attention is All You Need”的 Transformer 架构

上述架构用于机器翻译任务;因此,输入是源语言的单词序列,输出是目标语言的单词序列。编码器生成输入单词的向量表示,并将它们传递给解码器(编码器块指向解码器块的箭头表示词向量转移)。解码器接收这些输入单词向量,将输出单词转换为单词向量,并最终生成每个输出单词的概率(在图 6.1中标记为输出概率)。

变压器带来的创新在于多头注意力块。该块通过使用自注意力机制为每个单词创建一个密集表示。自注意力机制将输入句子中的每个单词与句子中的其他单词相关联。每个单词的词嵌入是通过取其他单词嵌入的加权平均来计算的。这样,就可以计算出输入句子中每个单词的重要性,因此架构依次关注每个输入单词。

下面的图示说明了 Transformer 模型中自注意力的机制。它展示了左侧的输入单词如何关注右侧的输入单词“它”。图中的颜色渐变表示每个单词与“它”的相关程度。颜色较深、较鲜艳的单词,如“动物”,相关性较高,而颜色较浅的单词,如“没”或“太累了”,相关性较低。

这个可视化演示了 Transformer 可以精确地确定这个句子中的代词“它”指的是“动物”。这种能力使得 Transformer 能够解决句子中的复杂语义依赖和关系,展示了它们理解上下文和意义的能力。

图 6.2 – 自注意力机制的说明

图 6.2 – 自注意力机制的说明

在本章的后面部分,我们将学习一个名为BERT的著名 Transformer 模型,所以如果现在所有这些内容看起来太抽象,请不要担心。我们将通过文本分类用例学习如何使用 Transformer,但在使用 Transformer 之前,我们需要了解如何使用 spaCy 解决文本分类问题。让我们在下一节中这样做。

使用 spaCy 进行文本分类

spaCy 模型在通用 NLP 目的上非常成功,例如理解句子的句法,将段落分割成句子,以及提取实体。然而,有时我们处理的是非常具体的领域,而 spaCy 预训练模型并没有学会如何处理这些领域。

例如,X(以前是 Twitter)文本包含许多非正规词,如标签、表情符号和提及。此外,X 句子通常只是短语,而不是完整的句子。在这里,spaCy 的 POS 标记器以不标准的方式表现是完全可以理解的,因为 POS 标记器是在完整的、语法正确的英语句子上训练的。

另一个例子是医疗领域。它包含许多实体,如药物、疾病和化学化合物名称。这些实体不被期望被 spaCy 的 NER 模型识别,因为它没有疾病或药物实体标签。NER 完全不知道医疗领域。

在本章中,我们将使用 Amazon Fine Food Reviews 数据集(www.kaggle.com/snap/amazon-fine-food-reviews)。这个数据集包含了关于在亚马逊上销售的精致食品的客户评论(J. McAuley 和 J. Leskovec. 隐藏因素和隐藏主题:通过评论文本理解评分维度。RecSys,2013dl.acm.org/doi/abs/10.1145/2507157.2507163)。评论包括用户和产品信息、用户评分和文本。我们希望将这些评论分类为正面或负面。由于这是一个特定领域的问题,spaCy(目前)不知道如何进行分类。为了教会管道如何做到这一点,我们将使用 TextCategorizer,这是一个可训练的文本分类组件。我们将在下一节中这样做。

训练 TextCategorizer 组件

TextCategorizer 是一个可选的可训练管道组件,用于预测整个文档的类别。要训练它,我们需要提供示例及其类别标签。图 6 .3 显示了 TextCategorizer 组件在 NLP 管道中的确切位置;该组件位于基本组件之后。

图 6.3 – NLP 管道中的 TextCategorizer

图 6.3 – NLP 管道中的 TextCategorizer

spaCy 的 TextCategorizer 组件背后是一个神经网络架构,它为我们提供了用户友好且端到端的训练分类器的途径。这意味着我们不必直接处理神经网络架构。TextCategorizer 有两种形式:单标签分类器(textcat)和多标签分类器(textcat_multilabel)。正如其名所示,多标签分类器可以预测多个类别。单标签分类器对每个示例只预测一个类别,且类别互斥。组件的预测结果以字典形式保存在 doc.cats 中,其中键是类别的名称,值是介于 0 和 1(包含)之间的分数。

要了解如何使用 TextCategorizer 组件,学习如何一般地训练深度模型是有帮助的。让我们在下一节中这样做。

训练深度学习模型

要训练一个神经网络,我们需要配置模型参数并提供训练示例。神经网络的每个预测都是其权重值的总和;因此,训练过程通过我们的示例调整神经网络的权重。如果你想了解更多关于神经网络如何工作的信息,你可以阅读优秀的指南neuralnetworksanddeeplearning.com/

在训练过程中,我们将多次遍历训练集,并多次展示每个示例。每次迭代被称为epoch。在每次迭代中,我们还会洗牌训练数据,以防止模型学习到与示例顺序相关的特定模式。这种训练数据的洗牌有助于确保模型能够很好地泛化到未见过的数据。

在每个 epoch 中,训练代码通过增量更新来更新神经网络的权重。这些增量更新通常通过将每个 epoch 的数据分成小批量来应用。通过比较实际标签与神经网络当前输出,计算出一个损失优化器是更新神经网络权重以适应该损失的函数。梯度下降是用于找到更新网络参数的方向和速率的算法。优化器通过迭代更新模型参数,使其朝着减少损失的方向移动。这就是简而言之的训练过程。

如果你曾经使用 PyTorch 或 TensorFlow 训练过深度学习模型,你可能会熟悉这个过程经常具有挑战性的性质。spaCy 使用 Thinc,这是一个轻量级的深度学习库,具有功能编程 API,用于组合模型。使用 Thinc,我们可以在不更改代码的情况下(并且无需直接使用这些库进行编码)在 PyTorch、TensorFlow 和 MXNet 模型之间切换。

Thinc 概念模型与其他神经网络库略有不同。为了训练 spaCy 模型,我们需要了解 Thinc 的配置系统。我们将在本章的下一节中介绍这一点。

总结训练过程,我们需要收集和准备数据,定义优化器以更新每个小批量的权重,将数据分成小批量,并对每个小批量进行洗牌以进行训练。

我们还没有涉及到收集和准备数据的阶段。spaCy 的示例容器包含一个训练实例的信息。它存储了两个Doc对象:一个用于存储参考标签,另一个用于存储管道的预测。让我们在下一节学习如何从我们的训练数据中构建示例对象。

为 spaCy 可训练组件准备数据

要创建用于训练的数据集,我们需要构建Example对象。可以使用带有 Doc 引用和金标准注释字典的Example.from_dict()方法来创建Example对象。对于TextCategorizer组件,Example的注释名称应该是表示文本类别相关性的cat标签/值对字典。

我们将要处理的数据集中的每个评论可以是正面或负面的。以下是一个评论示例:

review_text = 'This Hot chocolate is very good. It has just the right amount of milk chocolate flavor. The price is a very good deal and more than worth it!'
category = 'positive'

Example.from_dict()方法将Doc作为第一个参数,将Dict[str, Any]作为第二个参数。对于我们的分类用例,Doc将是评论文本,Dict[str, Any]将是带有标签和正确分类的cat字典。让我们为之前的评论构建Example

  1. 首先,让我们加载一个空的英文管道:

    import spacy
    from spacy.training import Example
    nlp = spacy.blank("en")
    
  2. 现在,让我们创建一个Doc来封装评论文本,并创建带有正确标签的cats字典:

    review_text = 'This Hot chocolate is very good. It has just the right amount of milk chocolate flavor. The price is a very good deal and more than worth it!'
    doc = nlp(review_text)
    annotation = {"cats": {"positive": 1, "negative": 0}}
    
  3. 最后,让我们创建一个Example对象:

    example = Example.from_dict(doc, annotation)
    

在本章中,我们仅微调TextCategorizer组件,但使用 spaCy,你也可以训练其他可训练组件,如TaggerDependencyParser。创建Example对象的过程是相同的;唯一不同的是每个对象的注释类型。以下是一些不同注释的示例(完整的列表可以在spacy.io/api/data-formats#dict-input找到):

  • text:原始文本

  • cats:表示特定文本类别对文本相关性的label** / **value对字典

  • tags:细粒度 POS 标签列表

  • deps:表示标记与其头标记之间的依赖关系的字符串值列表

amazon_food_reviews.csv文件是原始Amazon Fine Food Reviews数据集的 4,000 行样本。我们将从中取 80%用于训练,其余 20%用于测试。让我们创建包含所有训练示例的数组:

  1. 首先,让我们下载数据集:

    mkdir data
    wget -P data https://github.com/PacktPublishing/Mastering-spaCy-Second-Edition/blob/main/chapter_06/data/amazon_food_reviews.csv
    
  2. 现在,让我们加载并分割 80%的数据集用于训练:

    import pandas as pd
    import spacy
    from spacy.training import Example
    df = pd.read_csv("data/amazon_food_reviews.csv")
    df_train = df.sample(frac=0.8,random_state=200)
    df_test = df.drop(df_train.index)
    df_test.to_json("data/df_dev.json")
    
  3. 最后,让我们创建训练示例并将它们存储在一个列表中:

    nlp = spacy.blank("en")
    TRAIN_EXAMPLES = []
    for _,row in df_train.iterrows():
        if row["positive_review"] == 1:
            annotation = {"cats": {"positive": 1, "negative": 0}}
        else:
            annotation = {"cats": {"negative": 1, "positive": 0}}
        example = Example.from_dict(nlp(row["text"]), annotation)
        TRAIN_EXAMPLES.append(example)
    

现在你已经知道了如何创建示例来提供训练数据,我们可以继续编写训练脚本。

创建训练脚本

训练模型的推荐方法是使用 spaCy 的spacy train命令和 spaCy 的 CLI;我们不应该编写自己的训练脚本。在本节中,我们将为了学习目的编写自己的训练脚本。我们将在本章的下一节学习如何使用 CLI 正确训练模型。

让我们回顾一下训练深度学习模型的步骤。在每个 epoch 中,我们通过使用optimizer functions进行incremental updates来随机打乱训练数据并更新神经网络的权重。spaCy 提供了创建训练循环中所有这些步骤的方法。

我们的目标是训练TextCategorizer组件,因此第一步是创建它并将其添加到管道中。由于这是一个可训练的组件,我们需要提供Examples来初始化它。我们还需要提供当前的nlp对象。以下是创建和初始化组件的代码,使用我们在前面的代码块中创建的列表:

import spacy
from spacy.training import Example
nlp = spacy.blank("en")
textcat = nlp.add_pipe("textcat")
textcat.initialize(lambda: TRAIN_EXAMPLES, nlp=nlp)

我们将整个TRAIN_EXAMPLES列表作为一个lambda函数传递。下一步是定义优化器以更新模型权重。spaCy 的Language类有一个resume_training()方法,它创建并返回一个优化器。默认情况下,它返回Adam优化器,我们将坚持使用它。

我们准备好定义训练循环。对于每个 epoch,我们逐个遍历训练示例并更新textcat的权重。我们遍历数据 40 个 epochs。spaCy 的util.minibatch函数遍历项目批次。size参数定义了批次大小。我有一个内存足够的 GPU,所以我将数据分成 200 行的组。

重要提示

如果你运行代码并遇到“GPU out of memory”错误,你可以尝试减小size参数。

在训练数据循环到位后,下一步是最终计算模型预测与正确标签之间的差异,并相应地更新权重。Language类的update方法处理这一点。我们将提供数据和字典来更新损失,这样我们就可以跟踪它和之前创建的优化器。以下代码定义了完整的训练循环:

  1. 初始化管道和组件:

    import spacy
    from spacy.util import minibatch
    import random
    nlp = spacy.blank("en")
    textcat = nlp.add_pipe("textcat")
    textcat.initialize(lambda: TRAIN_EXAMPLES, nlp=nlp)
    
  2. 创建优化器:

    optimizer = nlp.resume_training()
    
  3. 定义训练循环:

    for epoch in range(40):
        random.shuffle(TRAIN_EXAMPLES)
        batches = minibatch(TRAIN_EXAMPLES, size=200)
        losses = {}
        for batch in batches:
            nlp.update(
                batch,
                losses=losses,
                sgd=optimizer,
            ) 
        if epoch % 10 == 0: 
            print(epoch, "Losses", losses) 
        print(epoch, "Losses", losses)
    

由此,你就有了一个第一个训练好的 spaCy 组件。如果一切正常,模型正在学习,损失应该会减少。在每 10 个 epoch 后,我们打印损失以检查这是否发生。让我们预测一些未见过的评论的分类,快速看一下模型的行为:

text = "Smoke Paprika My mother uses it for allot of dishes, but this particular one, doesn't compare to anything she had.  It is now being used for a decoration on the spice shelf and I will never use it and ruin a dish again. I have tried using just a little bit, thinking it was stronger than her's. And I am a decent cook. But this does not taste like the smoke paprika that I have had in the past.  Sorry I don't recommend this product at all."
doc = nlp(text)
print("Example 1", doc.cats)
text = "Terrible Tasting for me The Teechino Caffeine-Free Herbal Coffee, Mediterranean Vanilla Nut tasted undrinkable to me. It lacked a deep, full-bodied flavor, which Cafix and Pero coffee-like substitute products have. I wanted to try something new, and for me, this substitute coffee drink wasn't my favorite."
doc = nlp(text)
print("Example 2", doc.cats)
text = "Dishwater If I had a choice of THIS or nothing, I'd go with nothing. Of all the K-cups I've tasted - this is the worst. Very weak and if you close your eyes and think really hard about it, maybe you can almost taste cinnamon. Blech."
doc = nlp(text)
print("Example 3", doc.cats)

图 5.4 显示了结果:

图 6.4 – 评论示例的分类

图 6.4 – 评论示例的分类

该模型对前两个例子是正确的,但最后一个显然是负面评论,而模型将其分类为正面。我们可以看到,评论中包含一些非常客观的负面评论指标,例如段落这是最糟糕的。也许如果我们添加更多关于像 transformers 这样的单词上下文的信息,我们可以提高模型的表现。让我们在下一节尝试一下。

在 spaCy 中使用 Hugging Face transformers

在本章中,我们将使用 spaCy 的transformer组件(来自spacy-transformers)与textcat组件结合使用,以提高管道的准确性。这次,我们将使用 spaCy 的config.cfg系统创建管道,这是训练 spaCy 组件的推荐方式。

让我们先了解 Transformer 组件。

Transformer 组件

Transformer 组件由 spacy-transformers 包提供。使用 Transformer 组件,我们可以使用 transformer 模型来提高我们任务的准确性。该组件支持通过 Hugging Face transformers 库可用的所有模型。在本章中,我们将使用 RoBERTa 模型。我们将在本章的下一节中了解更多关于这个模型的信息。

TransformerDoc 对象添加一个 Doc._.trf_data 属性。这些 transformer 标记可以与其他管道组件共享。在本章中,我们将使用 RoBERTa 模型的标记作为 TextCategorizer 组件的一部分。但首先,让我们使用没有 TextCategorizer 的 RoBERTa 模型来查看它是如何工作的。Transformers 组件允许我们使用许多不同的架构。要使用 Hugging Face 的 roberta-base 模型,我们需要使用 spacy-transformers.TransformerModel.v3 架构。这就是我们这样做的方式:

  1. 导入库并加载一个空白模型:

    import spacy
    nlp = spacy.blank("en")
    
  2. 使用 Transformer 组件定义我们想要使用的架构。Transformer 组件接受一个 model 配置来设置包装 transformer 的 Thinc 模型。我们将架构设置为 spacy-transformers.TransformerModel.v3 并将模型设置为 roberta-base

    config = {
        "model": {
            "@architectures": "spacy-transformers.TransformerModel.v3",
            "name": "roberta-base"
        }
    }
    
  3. 将组件添加到管道中,初始化它,并打印向量:

    nlp.add_pipe("transformer", config=config)
    nlp.initialize()
    doc = nlp("Dishwater If I had a choice of THIS or nothing, I'd go with nothing. Of all the K-cups I've tasted - this is the worst. Very weak and if you close your eyes and think really hard about it, maybe you can almost taste cinnamon. Blech.")
    print(doc._.trf_data)
    

结果是一个包含 transformer 模型的输入和输出对象的批次的 FullTransformerBatch 对象。

很酷,现在我们需要使用这个模型输出与 TextCategorizer 组件一起使用。我们将使用 config.cfg 文件来完成,所以首先我们需要学习如何与这个配置系统一起工作。

spaCy 的配置系统

spaCy v3.0 引入了配置文件。这些文件用于包含训练管道的所有设置和超参数。在底层,训练配置使用 Thinc 库提供的配置系统。正如 spaCy 文档所指出的,spaCy 训练配置的一些主要优点和功能如下:

  • 结构化部分:配置被分组到部分中,嵌套部分使用 . 符号定义。例如,[components.textcat] 定义了管道的 TextCategorizer 组件的设置。

  • 插值:如果您有多个组件使用的超参数或其他设置,请定义一次,并作为变量引用。

  • 无隐藏默认值的可重复性:配置文件是“单一事实来源”并包含所有设置。

  • Automated checks and validation:当你加载一个配置时,spaCy 会检查设置是否完整,以及所有值是否具有正确的类型。这让你能够及早捕捉到潜在的错误。在你的自定义架构中,你可以使用 Python 类型提示来告诉配置期望哪些类型的数据。

配置被分为多个部分和子部分,由方括号和点符号表示。例如,[components]是一个部分,而[components.textcat]是一个子部分。配置文件的主要顶级部分如下:

  • paths:数据和其它资产的路由。在配置中作为变量重用(例如,${paths.train}),并且可以在命令行界面(CLI)上覆盖。

  • system:与系统和硬件相关的设置。在配置中作为变量重用(例如,${system.seed}),并且可以在命令行界面(CLI)上覆盖。

  • nlpnlp对象、其分词器和处理流程组件名称的定义。

  • components:流程组件及其模型的定义。

  • training:训练和评估过程的设置和控制。

  • pretraining:语言模型预训练的可选设置和控制。

  • initialize:在训练前调用nlp.initialize()时传递给组件的数据资源和参数(但不是在运行时)。

现在我们已经知道了如何训练一个深度学习模型,以及如何使用 Thinc 作为 spaCy 训练过程的一部分来定义此训练的配置。这个配置系统对于维护和重现 NLP 流程非常有用,并且不仅限于训练,还包括在我们不需要训练组件时构建流程。当与 spaCy CLI 结合使用时,spaCy 配置系统表现得尤为出色。

使用配置文件训练 TextCategorizer

在本节中,我们将使用 spaCy 的命令行界面(CLI)来微调分类流程。通常,训练模型的第一步是准备数据。使用 spaCy 进行训练时,推荐的方式是使用DocBin容器,而不是像之前那样创建Example对象。DocBin容器打包了一系列Doc对象,以便进行二进制序列化。

要使用DocBin创建训练数据,我们将使用评论的文本创建Doc对象,并相应地添加doc.cats属性。这个过程相当直接,我们只需要使用DocBin.add()方法添加一个Doc注释以进行序列化:

  1. 首先,我们像之前一样加载数据并进行分割:

    import pandas as pd
    import spacy
    from spacy.tokens import DocBin
    df = pd.read_csv("data/amazon_food_reviews.csv")
    df_train = df.sample(frac=0.8,random_state=200)
    nlp = spacy.blank("en")
    
  2. 现在,我们创建一个DocBin对象,并在for循环内部创建Doc对象并将它们添加到DocBin

    db = DocBin()
    for _,row in df_train.iterrows():
        doc = nlp(row["text"])
        if row["positive_review"] == 1:
            doc.cats = {"positive": 1, "negative": 0}
        else:
            doc.cats = {"positive": 0, "negative": 1}
        db.add(doc)
    
  3. 最后,我们将DocBin对象保存到磁盘:

    db.to_disk("data/train.spacy")
    
  4. 我们还需要创建一个dev测试集(它将在训练中使用),因此让我们创建一个函数来转换数据集:

    from pathlib import Path
    def convert_dataset(lang: str, input_path: Path, 
                        output_path: Path):
        nlp = spacy.blank(lang)
        db = DocBin()
        df = pd.read_json(input_path)
        for _,row in df.iterrows():
            doc = nlp.make_doc(row["Text"])
            if row["positive_review"] == 1:
                doc.cats = {"positive": 1, "negative": 0}
            else:
                doc.cats = {"negative": 1, "positive": 0}
            db.add(doc)
        db.to_disk(output_path)
    convert_dataset("en", "data/df_dev.json", "data/dev.spacy")
    

现在,我们已经以推荐的方式准备好了所有数据以训练模型。我们将在一分钟内使用这些 .spacy 文件;让我们首先学习如何使用 spaCy CLI。

spaCy 的 CLI

使用 spaCy 的 CLI,您可以通过命令行执行 spaCy 操作。使用命令行很重要,因为我们可以创建和自动化管道的执行,确保每次运行管道时,它都会遵循相同的步骤。

spaCy 的 CLI 提供了用于训练管道、转换数据、调试配置文件、评估模型等命令。您可以通过输入 python -m spacy --help 来查看所有 CLI 命令的列表。

spacy train 命令用于训练或更新一个 spaCy 管道。它需要 spaCy 的二进制格式数据,但您也可以使用 spacy convert 命令将数据从其他格式转换过来。配置文件应包含训练过程中使用的所有设置和超参数。我们可以使用命令行选项来覆盖设置。例如,--training.batch_size 128 会覆盖 "[** **training]" 块中 "batch_size" 的值。

我们将使用 spacy init config CLI 命令来创建配置文件。配置文件中的信息包括以下内容:

  • 转换数据集的路径

  • 一个种子数和 GPU 配置

  • 如何创建 nlp 对象

  • 如何构建我们将使用的组件

  • 如何进行训练本身

对于训练,明确所有设置 非常重要。我们不希望有隐藏的默认值,因为它们可以使管道难以重现。这是配置文件设计的一部分。

让我们创建一个不使用 Transformer 组件的训练配置,以了解训练模型的正确方式:

python3 -m spacy init config config_without_transformer.cfg --lang “en” --pipeline “textcat”

此命令使用英语模型和一个 TextCategorizer 组件创建一个名为 config_without_transformer.cfg 的配置文件,并默认定义了所有其他设置。

在文件中,在 paths 部分中,我们应该指向 traindev 数据路径。然后,在 system 部分中,我们设置随机种子。spaCy 使用 CuPy 来支持 GPU。CuPy 为 GPU 数组提供了一个与 NumPy 兼容的接口。gpu_allocator 参数设置 CuPy 将 GPU 内存分配路由到哪个库,其值可以是 pytorchtensorflow。这避免了在使用 CuPy 与这些库之一一起使用时出现的内存问题,但由于现在的情况,我们可以将其设置为 null

nlp 部分,我们指定我们将使用的模型并定义管道的组件,目前只是 textcat。在 components 部分,我们需要指定如何初始化组件,因此我们在 component.textcat 子部分中设置了 factory = "textcat" 参数。textcat 是创建 TextCategorizer 组件的注册函数的名称。您可以在 spacy.io/api/data-formats#config 上看到所有可用的配置参数。

配置设置完成后,我们可以运行 spacy train 命令。这次运行的输出是一个新的管道,因此您需要指定一个路径来保存它。以下是运行训练过程的完整命令:

python3 -m spacy train config_without_transformer.cfg --paths.train "data/train.spacy" --paths.dev "data/dev.spacy" --output pipeline_without_transformer/

这个命令使用我们创建的配置文件训练管道,并指向 train.spacydev.spacy 数据。图 6 .5 展示了训练输出。

图 6.5 – 训练输出

图 6.5 – 训练输出

E 表示时代,你还可以看到每个优化步骤的损失和分数。最佳模型保存在 pipeline_without_transformer/model-last 。让我们加载它并检查前述示例的结果:

import spacy
nlp = spacy.load("pipeline_without_transformer/model-best")
text = "Smoke Paprika My mother uses it for allot of dishes, but this particular one, doesn't compare to anything she had.  It is now being used for a decoration on the spice shelf and I will never use it and ruin a dish again. I have tried using just a little bit, thinking it was stronger than her's. And I am a decent cook. But this does not taste like the smoke paprika that I have had in the past.  Sorry I don't recommend this product at all."
doc = nlp(text)
print("Example 1", doc.cats)
text = "Terrible Tasting for me The Teechino Caffeine-Free Herbal Coffee, Mediterranean Vanilla Nut tasted undrinkable to me. It lacked a deep, full-bodied flavor, which Cafix and Pero coffee-like substitute products have. I wanted to try something new, and for me, this substitute coffee drink wasn't my favorite."
doc = nlp(text)
print("Example 2", doc.cats)
text = "Dishwater If I had a choice of THIS or nothing, I'd go with nothing. Of all the K-cups I've tasted - this is the worst. Very weak and if you close your eyes and think really hard about it, maybe you can almost taste cinnamon. Blech."
doc = nlp(text)
print("Example 3", doc.cats)

图 6 .6 展示了结果。

图 6.6 – 使用新流程的审查示例类别

图 6.6 – 使用新流程的审查示例类别

现在,模型对前两个示例是不正确的,对第三个示例是正确的。让我们看看我们是否可以使用 transformers 组件来改进这一点。在这样做之前,现在是学习最有影响力的变压器模型之一,BERT 的内部结构的好时机。然后,我们将了解其继任者,RoBERTa,这是我们将在本章分类用例中使用的模型。

BERT 和 RoBERTa

在本节中,我们将探讨最有影响力和最常用的 Transformer 模型,BERT。BERT 在 Google 2018 年的研究论文中被介绍;您可以在以下链接中阅读:arxiv.org/pdf/1810.04805.pdf

BERT 究竟做了什么?为了理解 BERT 的输出,让我们剖析一下这个名字:

Bidirectional: Training on the text data is bi-directional, which means each input sentence is processed from left to right as well as from right to left.
Encoder: An encoder encodes the input sentence.
Representations: A representation is a word vector.
Transformers: The architecture is transformer-based.

BERT 实质上是一个训练好的变压器编码器堆栈。BERT 的输入是一个句子,输出是一个单词向量的序列。BERT 与之前的词向量技术之间的区别在于,BERT 的词向量是上下文相关的,这意味着一个向量是根据输入句子分配给一个单词的。

类似于 GloVe 的词向量是上下文无关的,这意味着一个单词的词向量在句子中使用时总是相同的,不受句子上下文的影响。以下图表解释了这个问题:

图 6.7 – “bank”单词的词向量

图 6.7 – “bank”单词的词向量

在这里,尽管这两个句子中的单词 bank 有两种完全不同的含义,但词向量是相同的。每个单词只有一个向量,并且向量在训练后保存到文件中。

相反,BERT 词向量是动态的。BERT 可以根据输入句子为同一单词生成不同的词向量。以下图表显示了 BERT 生成的词向量:

图 6.8 – BERT 在两个不同语境下为同一单词“bank”生成的两个不同的词向量

图 6.8 – BERT 在两个不同语境下为同一单词“bank”生成的两个不同的词向量

BERT 是如何生成这些词向量的?在下一节中,我们将探讨 BERT 架构的细节。

BERT 架构

BERT 是一个变压器编码器堆叠,这意味着几个编码器层堆叠在一起。第一层随机初始化词向量,然后每个编码器层将前一个编码器层的输出进行转换。论文介绍了两种 BERT 模型大小:BERT Base 和 BERT Large。以下图表显示了 BERT 架构:

图 6.9 – BERT Base 和 Large 架构,分别有 12 和 24 个编码器层

图 6.9 – BERT Base 和 Large 架构,分别有 12 和 24 个编码器层

两个 BERT 模型都有大量的编码器层。BERT Base 有 12 个编码器层,BERT Large 有 24 个编码器层。生成的词向量维度也不同;BERT Base 生成 768 大小的词向量,BERT Large 生成 1024 大小的词向量。

以下图表展示了 BERT 输入和输出的高级概述(现在忽略 CLS 标记;你将在 BERT 输入 格式 部分学习有关它的内容):

图 6.10 – BERT 模型输入词和输出词向量

图 6.10 – BERT 模型输入词和输出词向量

在前面的图表中,我们可以看到 BERT 输入和输出的高级概述。BERT 输入必须以特殊格式,并包含一些特殊标记,如 图 6.10 中的 CLS。在下一节中,你将了解 BERT 输入格式的细节。

BERT 输入格式

要理解 BERT 如何生成输出向量,我们需要了解 BERT 输入数据格式。BERT 输入格式可以表示单个句子,也可以表示一对句子。对于问答和语义相似度等任务,我们将两个句子作为一个标记序列输入到模型中。

BERT 与一类特殊标记和一种称为 WordPiece 的特殊标记化算法一起工作。主要的特殊标记是 [CLS][SEP][PAD]

  • BERT 的第一个特殊标记是 [ CLS ]。每个输入序列的第一个标记必须是 [ CLS ]。我们在分类任务中使用此标记作为输入句子的汇总。在非分类任务中,我们忽略此标记。

  • [ SEP ] 表示句子分隔符。如果输入是一个单独的句子,我们将此标记放置在句子的末尾。如果输入是两个句子,则使用此标记来分隔两个句子。因此,对于单个句子,输入看起来像 [ CLS ] 句子 [ SEP ],而对于两个句子,输入看起来像 [ CLS ] 句子 1 [ SEP ] 句子 2 [ SEP ]。

  • [ PAD ] 是一个特殊标记,表示填充。BERT 接收固定长度的句子;因此,我们在将句子输入到 BERT 之前对其进行填充。我们可以输入到 BERT 中的标记的最大长度是 512。

BERT 使用 WordPiece 分词对单词进行分词。一个“词片”字面上就是一个单词的一部分。WordPiece 算法将单词分解成几个子词。其想法是将复杂/长的标记分解成更简单的标记。例如,单词 playing 被分词为 play##ing。一个 ## 字符放置在每个词片之前,以指示此标记不是语言词汇中的单词,而是词片。

让我们看看更多的例子:

playing  play, ##ing
played   play, ##ed
going    go, ##ing
vocabulary = [play, go, ##ing, ##ed]

通过这样做,我们更紧凑地表示语言词汇,将常见的子词分组。WordPiece 分词在罕见/未见过的单词上创造了奇迹,因为这些单词被分解成它们的子词。

在对输入句子进行分词并添加特殊标记后,每个标记被转换为它的 ID。之后,我们将标记 ID 的序列输入到 BERT。

总结来说,这是我们将句子转换为 BERT 输入格式的方法:

图 6.11 – 将输入句子转换为 BERT 输入格式

图 6.11 – 将输入句子转换为 BERT 输入格式

这个分词过程对于转换器模型至关重要,因为它允许模型处理词汇表外的单词,并有助于泛化。例如,模型可以学习到像 happinesssadness 这样的单词中 ness 后缀具有特定的含义,并且可以使用这种知识来处理具有相同后缀的新单词。

BERT 在一个大型未标记的 Wiki 语料库和庞大的书籍语料库上训练。如 Google Research 的 BERT GitHub 仓库 github.com/google-research/bert 中所述,他们在一个大型语料库(维基百科 + BookCorpus)上长时间(1M 更新步骤)训练了一个大型模型(12 层到 24 层的转换器)。

BERT 使用两种训练方法进行训练:掩码语言模型MLM)和下一个句子预测NSP)。语言模型是预测给定先前标记序列的下一个标记的任务。例如,给定单词序列 Yesterday I visited a,语言模型可以预测下一个标记为诸如 churchhospitalschool 等标记之一。掩码语言模型是一种语言模型,其中我们通过用 掩码标记随机替换一定比例的标记来掩码。我们期望 MLM 能够预测掩码的单词。

在 BERT 中的掩码语言模型数据准备如下。首先,随机选择 15 个输入标记。然后,发生以下情况:

  • 所选择的标记中有 80% 被替换为 粗体标记

  • 所选择的标记中有 10% 被替换为词汇表中的另一个标记

  • 剩余的 10% 保持不变

MLM 的一个训练示例句子如下:

[CLS] Yesterday I [MASK] my friend at [MASK] house [SEP]

NSP 是根据输入句子预测下一个句子的任务。在这个方法中,我们向 BERT 输入两个句子,并期望 BERT 预测句子的顺序,更具体地说,是否第二个句子是跟在第一个句子后面的句子。

让我们做一个 NSP 的示例输入。我们将以 [SEP] 标记分隔的两个句子作为输入:

[CLS] A man robbed a [MASK] yesterday [MASK] 8 o'clock [SEP]
He [MASK] the bank with 6 million dollars [SEP]
Label = IsNext

在这个例子中,第二句话可以跟在第一句话后面;因此,预测的标签是 IsNext。这个例子怎么样?

[CLS] Rabbits like to [MASK] carrots and [MASK] leaves [SEP]
[MASK] Schwarzenegger is elected as the governor of [MASK] [SEP]
Label= NotNext

这对句子示例生成的是 NotNext 标签,因为它们在上下文或语义上不相关。

这两种训练技术都允许模型学习关于语言的复杂概念。Transformer 是 LLM 的基础。LLM 正在改变 NLP 世界,这主要是因为它们理解上下文的能力。

现在你已经了解了 BERT 架构、输入格式的细节以及训练数据准备,你有了理解 LLM 的工作原理的坚实基础。回到我们的分类用例,我们将使用 BERT 的一个后继模型,即 RoBERTa。让我们在下一节中了解 RoBERTa。

RoBERTa

RoBERTa 模型在 arxiv.org/abs/1907.11692 中被提出。它建立在 BERT 的基础上,它们之间的关键区别在于数据准备和训练。

BERT 在数据预处理期间进行一次标记掩码,这导致每个训练实例在每个 epoch 中都有相同的掩码。RoBERTa 使用 动态掩码,每次我们向模型输入一个序列时,它们都会生成掩码模式。他们还移除了 NSP,因为他们发现它匹配或略微提高了下游任务性能。

RoBERTa 也比 BERT 使用更大的批量大小和更大的词汇量,从 30K 的词汇量到包含 50K 子词单元的词汇量。这篇论文是一篇非常好的读物,可以了解影响 Transformer 模型的设计决策。

既然我们已经了解了 BERT 和 RoBERTa 的工作原理,现在是时候最终在我们的文本分类管道中使用 RoBERTa 了。

使用转换器训练 TextCategorizer

要与transformer组件进行下游任务,我们需要告诉 spaCy 如何将组件输出与其他管道组件连接起来。我们将使用spacy-transformers.TransformerModel.v3spacy-transformers.TransformerListener.v1层来完成这项工作。

在 spaCy 中,我们有不同的模型架构,这些是连接 Thinc Model实例的函数。TransformerModelTransformerListener模型都是转换器层。

spacy-transformers.TransformerModel.v3层加载并包装了来自 Hugging Face Transformers 库的转换器模型。它与任何具有预训练权重和 PyTorch 实现的转换器一起工作。spacy-transformers.TransformerListener.v1层接受一个Doc对象列表作为输入,并使用TransformerModel层生成一个二维数组列表作为输出。

现在你已经了解了 spaCy 层概念,是时候回顾TextCategorizer组件了。在 spaCy 中,TextCategorizer组件有不同的模型架构层。通常,每个架构接受子层作为参数。默认情况下,TextCategorizer组件使用spacy.TextCatEnsemble.v2层,这是一个线性词袋模型和神经网络模型的堆叠集成。我们在使用配置文件训练管道时使用了这个层。

为了结束本章的旅程,我们将TextCatEnsemble的神经网络层从默认的spacy.Tok2Vec.v2层更改为 RoBERTa 转换器模型。我们将通过创建一个新的配置文件来完成这项工作:

python3 -m spacy init config config_transformer.cfg --lang "en" --pipeline "textcat" --optimize "accuracy" --gpu

此命令创建了一个针对准确性和 GPU 训练优化的config_transformer.cfg文件。图 6.12显示了命令的输出。

图 6.12 – 创建使用 RoBERTa 的新训练配置

图 6.12 – 创建使用 RoBERTa 的新训练配置

现在,我们可以通过指向此配置文件来训练管道,训练模型,并做出预测,就像我们在上一节中所做的那样:

python3 -m spacy train config_transformer.cfg --paths.train "data/train.spacy" --paths.dev "data/dev.spacy" --output pipeline_transformer/ --gpu-id 0

这次,我们使用 GPU 进行训练,因此设置了gpu_id参数。图 6.13显示了使用这个新训练模型的结果。

图 6.13 – 使用转换器管道的评论示例类别

图 6.13 – 使用转换器管道的评论示例类别

现在,模型能够正确地分类评论。不错,不是吗?

摘要

可以说,这一章是本书最重要的章节之一。在这里,我们学习了迁移学习和转换器,以及如何使用 spaCy 配置系统来训练TextCategorizer组件。

通过了解如何准备数据以训练 spaCy 组件以及如何使用配置文件来定义训练设置的知识,你现在能够微调任何 spaCy 可训练组件。这是一个巨大的进步,恭喜你!

在本章中,你学习了关于语言模型的内容。在下一章中,你将学习如何使用 LLMs,它们是目前最强大的 NLP 模型。

第三部分:定制和集成 NLP 工作流程

本节重点介绍创建定制的 NLP 解决方案以及将 spaCy 与其他工具和平台集成。你将了解如何利用大型语言模型(LLMs)、训练自定义模型以及将 spaCy 项目与网络应用程序集成以构建端到端解决方案。

本部分包含以下章节:

  • 第七章使用 spacy-llm 增强 NLP 任务

  • 第八章使用您自己的数据训练 NER 组件

  • 第九章使用 Weasel 创建端到端 spaCy 工作流程

  • 第十章使用 spaCy 训练实体链接器模型

  • 第十一章将 spaCy 与第三方库集成

第七章:使用 spacy-llm 增强 NLP 任务

在本章中,我们将基于在第六章中获得的知识,探讨如何使用spacy-llm库将大型语言模型LLMs)集成到 spaCy 管道中。我们将从理解 LLMs 和提示工程的基础知识开始,以及这些强大的模型如何在 spaCy 中执行各种 NLP 任务。我们将演示如何配置和使用预构建的 LLM 任务,如文本摘要,然后进一步创建一个自定义任务,从文本中提取上下文信息。这涉及到使用 Jinja 模板创建提示,并编写自定义 spaCy 组件,以高效地处理复杂的 NLP 任务。到本章结束时,你将更深入地了解如何利用 LLMs 的灵活性和强大功能来增强传统的 NLP 管道。

在本章中,我们将涵盖以下主要主题:

  • LLMs 和提示工程基础知识

  • 使用 LLMs 和 spaCy 进行文本摘要

  • 使用 Jinja 模板创建自定义 LLM 任务

技术要求

在本章中,我们将使用 spaCy 和spacy-llm库来创建和运行我们的管道。你可以在此章节中找到使用的代码:github.com/PacktPublishing/Mastering-spaCy-Second-Edition

LLMs 和提示工程基础知识

正如我们在第六章中看到的,语言建模是预测给定先前标记序列的下一个标记的任务。我们使用的例子是,给定单词序列昨天我访问了一个,语言模型可以预测下一个标记可能是教堂医院学校等。传统的语言模型通常以监督方式训练以执行特定任务。预训练语言模型PLM)以自监督方式训练,目的是学习语言的通用表示。然后对这些 PLM 模型进行微调以执行特定的下游任务。这种自监督的预训练使 PLM 模型比常规语言模型更强大。

LLMs(大型语言模型)是 PLMs(预训练语言模型)的进化,拥有更多的模型参数和更大的训练数据集。例如,GPT-3 模型拥有 1750 亿个参数。其继任者 GPT3.5 是 2022 年 11 月发布的 ChatGPT 模型的基础。LLMs 可以作为通用工具,能够执行从语言翻译到编码辅助的各种任务。它们理解和生成类似人类文本的能力,在医学、教育、科学、数学、法律等领域产生了重大影响。在医学领域,LLMs 为医生提供基于证据的建议,并增强患者互动。在教育领域,它们定制学习体验,并协助教师创建内容。在科学领域,LLMs 加速研究和科学写作。在法律领域,它们分析法律文件并阐明复杂术语。

我们还可以使用 LLMs 进行常规 NLP 任务,如命名实体识别NER)、文本分类和文本摘要。基本上,这些模型可以完成我们要求的几乎所有事情。但这是有代价的,因为训练它们需要大量的计算资源,大量的层和参数使它们产生答案的速度比非 LLM 模型慢得多。LLMs 也可能产生幻觉:产生看似合理但实际上不正确或与事实或上下文不一致的响应。这种现象发生是因为模型根据从训练数据中学习的模式生成文本,而不是通过外部来源验证信息。因此,它们可能会创建听起来合理但实际上具有误导性、不准确或完全虚构的陈述。鉴于所有这些,LLMs 是有用的,但我们始终应该分析它们是否是手头项目的最佳解决方案。

要与 LLMs 交互,我们使用提示。提示应引导模型生成答案或使模型采取行动。提示通常包含以下元素:

  • 指令:您希望模型执行的任务

  • 上下文:对产生更好答案有用的外部信息或附加上下文

  • 输入数据:我们想要得到答案的输入/问题

  • 输出指示器:我们希望模型输出的格式类型

使用spacy-llm,我们将提示定义为任务。当使用 LLMs 构建 spaCy 管道时,每个 LLM 组件都使用一个任务和一个模型来定义。任务定义了提示和解析结果的提示和功能。模型定义了 LLM 模型以及如何连接到它。

现在您已经了解了 LLMs 是什么以及如何与它们交互,让我们在管道中使用一个spacy-llm组件。在下一节中,我们将创建一个使用 LLM 来总结文本的管道。

使用 LLMs 和 spacy-llm 进行文本摘要

每个 spacy-llm 组件都有一个任务定义。spaCy 有一些预定义的任务,我们也可以创建自己的任务。在本节中,我们将使用 spacy.Summarization.v1 任务。每个任务都是通过一个提示来定义的。以下是该任务的提示,可在 github.com/explosion/spacy-llm/blob/main/spacy_llm/tasks/templates/summarization.v1.jinja 找到:

You are an expert summarization system. Your task is to accept Text as input and summarize the Text in a concise way.
{%- if max_n_words -%}
{# whitespace #}
The summary must not, under any circumstances, contain more than {{ max_n_words }} words.
{%- endif -%}
{# whitespace #}
{%- if prompt_examples -%}
{# whitespace #}
Below are some examples (only use these as a guide):
{# whitespace #}
{%- for example in prompt_examples -%}
{# whitespace #}
Text:
'''
{{ example.text }}
'''
Summary:
'''
{{ example.summary }}
'''
{# whitespace #}
{%- endfor -%}
{# whitespace #}
{%- endif -%}
{# whitespace #}
Here is the Text that needs to be summarized:
'''
{{ text }}
'''
Summary:

spacy-llm 使用 Jinja 模板来定义指令和示例。Jinja 使用占位符在模板中动态插入数据。最常见的占位符是 {{ }}{% %}{# #}{{ }} 用于添加变量或表达式,{% %} 与流程控制语句一起使用,{# #} 用于添加注释。让我们看看这些占位符如何在 spacy.Summarization.v1 模板中使用。

我们可以要求模型输出具有特定最大单词数的摘要。max_n_words 的默认值是 null。如果我们在配置中设置了此参数,模板将包括此数字:

{%- if max_n_words -%}
{# whitespace #}
The summary must not, under any circumstances, contain more than {{ max_n_words }} words.
{%- endif -%}

Few-shot prompting 是一种技术,它包括提供一些期望的输入和输出示例(通常为 1 到 5 个),以展示我们希望模型如何生成结果。这些示例有助于模型更好地理解模式,而无需使用大量示例进行微调。spacy.Summarization.v1 任务有一个 examples 参数来生成少量示例。

现在我们已经了解了总结模板任务的工作原理,是时候开始处理 spacy-llm 的其他元素了,即 模型。我们将使用 Anthropic 的 Claude 2 模型。为了确保连接到该模型的凭据可用,您可以在计算机上的控制台中运行 export ANTHROPIC_API_KEY="..." 命令。现在让我们使用 python3 -m pip install spacy-llm==0.7.2 命令安装该包。我们将使用 config.cfg 文件来加载管道(如果您需要复习,可以回到 第六章 中的 使用 spaCy config.cfg 文件 部分)。让我们构建配置文件:

  1. 首先,我们将定义 nlp 部分,其中我们应该定义我们的管道的语言和组件。我们只使用 llm 组件:

    [nlp]
    lang = "en"
    pipeline = ["llm"]
    
  2. 现在,是时候指定 components 部分。为了初始化 llm 组件,我们将写入 factory = "llm";然后,我们将指定任务和模型:

    [components]
    [components.llm]
    factory = "llm"
    [components.llm.task]
    @llm_tasks = "spacy.Summarization.v1"
    examples = null
    max_n_words = null
    [components.llm.model]
    @llm_models = "spacy.Claude-2.v2"
    config = {"max_tokens_to_sample": 1024}
    
  3. 为了加载此管道,我们将通过 spacy_llm.utilassemble 方法传递此配置文件的路径。让我们要求模型总结本章的 LLMs 和提示工程基础 部分:

    from spacy_llm.util import assemble
    nlp = assemble("config.cfg")
    content = """
    As we saw on Chapter 6, Language Modeling is the task of predicting the next token given the sequence of previous tokens.
    [...]
    Now that you know what LLMs are and how to interact with them, let's use a spacy-llm component in a pipeline. In the next section we're going to create a pipeline to summarize texts using a LLM.
    """
    doc = nlp(content)
    print(doc._.summary)
    
  4. spacy.Summarization.v1 任务默认将摘要添加到 ._.summary 扩展属性中。以下是模型的响应:

    'Here is a concise summary of the key points from the text:\n\nLanguage models predict the next token in a sequence. Pre-trained language models (PLMs) are trained in a self-supervised way to learn general representations of language. PLMs are fine-tuned for downstream tasks. Large language models (LLMs) like GPT-3 have billions of parameters and are trained on huge datasets. LLMs can perform a variety of tasks including translation, coding assistance, scientific writing, and legal analysis. However, LLMs require lots of compute resources, are slow, and can sometimes "hallucinate" plausible but incorrect information. We interact with LLMs using prompts that provide instructions, context, input data, and indicate the desired output format. Spacy-llm allows defining LLM components in spaCy pipelines using tasks to specify prompts and models to connect to the LLM. The text then explains we will create a pipeline to summarize text using a LLM component.'
    

很好,对吧?你可以检查其他参数来在此处自定义任务 spacy.io/api/large-language-models#summarization-v1 。一些其他可用的 spacy-llm 任务包括 spacy.EntityLinker.v1** , **spacy.NER.v3** , **spacy.SpanCat.v3** , **spacy.TextCat.v3 , 和 spacy.Sentiment.v1 。但除了使用这些预构建的任务之外,你还可以创建自己的任务,这不仅增强了 spacy-llm 的功能,而且为构建 NLP 管道时遵循最佳实践提供了一种有组织的方式。让我们在下一节中学习如何做到这一点。

创建自定义 spacy-llm 任务

在本节中,我们将创建一个任务,给定一个来自 dummyjson.com/docs/quotes 的引用,模型应提供引用的上下文。以下是一个示例:

Quote: We must balance conspicuous consumption with conscious capitalism.
Context: Business ethics.

创建自定义 spacy-llm 任务的第一个步骤是创建提示并将其保存为 Jinja 模板。以下是此任务的模板:

You are an expert at extracting context from text.
Your tasks is to accept a quote as input and provide the context of the quote.
This context will be used to group the quotes together.
Do not put any other text in your answer and provide the context in 3 words max. The quote should have one context only.
{# whitespace #}
{# whitespace #}
Here is the quote that needs classification
{# whitespace #}
{# whitespace #}
Quote:
'''
{{ text }}

我们将这个保存到名为 templates/quote_context_extract.jinja 的文件中。下一步是创建任务的类。这个类应该实现两个函数:

  • generate_prompts(docs: Iterable[Doc]) -> Iterable[str] : 这个函数将 spaCy Doc 对象列表转换为提示列表。

  • parse_responses(docs: Iterable[Doc], responses: Iterable[str]) -> Iterable[Doc] : 这个函数将 LLM 输出解析为 spaCy Doc 对象。

generate_prompts() 方法将使用我们的 Jinja 模板,而 parse_responses() 方法将接收模型的响应并添加上下文扩展属性到我们的 Doc 对象。让我们创建 QuoteContextExtractTask 类:

  1. 首先,我们导入所有需要的函数并设置模板的目录:

    from pathlib import Path
    from spacy_llm.registry import registry
    import jinja2
    from typing import Iterable
    from spacy.tokens import Doc
    TEMPLATE_DIR = Path("templates")
    
  2. 现在,让我们创建一个方法来读取 Jinja 模板中的文本:

    def read_template(name: str) -> str:
        """Read the text from a Jinja template using pathlib"""
        path = TEMPLATE_DIR / f"{name}.jinja"
        if not path.exists():
            raise ValueError(f"{name} is not a valid template.")
        return path.read_text()
    
  3. 最后,我们可以开始创建 QuoteContextExtractTask 类。让我们从创建 __init__() 方法开始。这个类应该使用 Jinja 模板的名称和一个字段字符串来设置任务将添加到 Doc 对象的扩展属性名称:

    class QuoteContextExtractTask:
        def __init__(self, template: str = "quotecontextextract",
                     field: str = "context"):
            self._template = read_template(template)
            self._field = field
    
  4. 现在我们将创建一个方法来构建提示。Jinja 使用 Environment 对象从文件中加载模板。我们将使用 from_string() 方法从文本构建模板并生成它。每次此方法内部运行时,它将渲染模板,用模板中的 {{text}} 变量的值替换模板的 doc.text :

    def generate_prompts(self, docs: Iterable[Doc]) -> Iterable[str]:
        environment = jinja2.Environment()
        _template = environment.from_string(self._template)
        for doc in docs:
            prompt = _template.render(
                text=doc.text,
            )
            yield prompt
    
  5. 我们现在可以编写类的最后一个方法。parse_responses() 方法将添加模型的响应到 Doc 对象。首先,我们创建一个辅助方法来添加扩展属性,如果它不存在。为了设置扩展属性,我们将使用 Python 的 setattr () 方法,这样我们就可以使用类字段变量动态设置属性:

      def _check_doc_extension(self):
          """Add extension if need be."""
          if not Doc.has_extension(self._field):
              Doc.set_extension(self._field, default=None)
        def parse_responses(
            self, docs: Iterable[Doc], responses: Iterable[str]
        ) -> Iterable[Doc]:
            self._check_doc_extension()
            for doc, prompt_response in zip(docs, responses):
                try:
                    setattr(
                        doc._,
                        self._field,
                        prompt_response[0].strip(),
                    )
                except ValueError:
                    setattr(doc._, self._field, None)
                yield doc
    
  6. 要在config.cfg文件中使用这个类,我们需要将任务添加到spacy-llmllm_tasks注册表中:

    @registry.llm_tasks("my_namespace.QuoteContextExtractTask.v1")
    def make_quote_extraction() -> "QuoteContextExtractTask":
        return QuoteContextExtractTask()
    

完成了!将此保存到quote.py类中。以下是应该在这个脚本中的完整代码:

from pathlib import Path
from spacy_llm.registry import registry
import jinja2
from typing import Iterable
from spacy.tokens import Doc
TEMPLATE_DIR = Path("templates")
def read_template(name: str) -> str:
    """Read the text from a Jinja template using pathlib"""
    path = TEMPLATE_DIR / f"{name}.jinja"
    if not path.exists():
        raise ValueError(f"{name} is not a valid template.")
    return path.read_text()
@registry.llm_tasks("my_namespace.QuoteContextExtractTask.v1")
def make_quote_extraction() -> "QuoteContextExtractTask":
    return QuoteContextExtractTask()
class QuoteContextExtractTask:
    def __init__(self, template: str = "quote_context_extract",
                 field: str = "context"):
        self._template = read_template(template)
        self._field = field
    def generate_prompts(self, 
        docs: Iterable[Doc]) -> Iterable[str]:
        environment = jinja2.Environment()
        _template = environment.from_string(self_template)
        for doc in docs:
            prompt = _template.render(
                text=doc.text,
            )
            yield prompt
    def _check_doc_extension(self):
        """Add extension if need be."""
        if not Doc.has_extension(self._field):
            Doc.set_extension(self._field, default=None)
  def parse_responses(
      self, docs: Iterable[Doc], responses: Iterable[str]
  ) -> Iterable[Doc]:
        self._check_doc_extension()
        for doc, prompt_response in zip(docs, responses):
            try:
                setattr(
                    doc._,
                    self._field,
                    prompt_response[0].strip(),
                ),
            except ValueError:
                setattr(doc._, self._field, None)
            yield doc
  1. 现在,让我们通过首先创建config_custom_task.cfg文件来测试我们的自定义任务:

    [nlp]
    lang = "en"
    pipeline = ["llm"]
    [components]
    [components.llm]
    factory = "llm"
    [components.llm.task]
    @llm_tasks = "my_namespace.QuoteContextExtractTask.v1"
    [components.llm.model]
    @llm_models = "spacy.Claude-2.v2"
    config = {"max_tokens_to_sample": 1024}
    
  2. 最后,我们可以使用这个文件组装nlp对象,并打印出引语的上下文。别忘了从quote.py导入QuoteContextExtractTask,这样 spaCy 就知道从哪里加载这个任务:

    from spacy_llm.util import assemble
    from quote import QuoteContextExtractTask
    nlp = assemble("config_custom_task.cfg")
    quote = "Life isn't about getting and having, it's about giving and being."
    doc = nlp(quote)
    print("Context:", doc._.context)
    >>> Context: self-improvement
    

在本节中,你创建了一个自定义的spacy-llm任务来从给定的引语中提取上下文。这种方法不仅允许你根据需求定制高度具体的 NLP 任务,而且还提供了一种结构化的方式将软件工程的最佳实践,如模块化和可重用性,集成到你的 NLP 管道中。

摘要

在本章中,我们探讨了如何利用大型语言模型LLMs)在 spaCy 管道中使用spacy-llm库。我们回顾了 LLMs 和提示工程的基础知识,强调它们作为多功能工具在执行各种 NLP 任务中的作用,从文本分类到摘要。然而,我们也指出了 LLMs 的局限性,例如它们的高计算成本和产生幻觉的倾向。然后,我们展示了如何通过定义任务和模型将 LLMs 集成到 spaCy 管道中。具体来说,我们实现了一个摘要任务,随后创建了一个自定义任务来从引语中提取上下文。这个过程涉及到创建提示的 Jinja 模板和定义生成和解析响应的方法。

在下一章中,我们将回到更传统的机器学习,学习如何使用 spaCy 从头开始标注数据和训练管道组件。

第八章:使用自己的数据训练 NER 组件

在本章中,你将学习如何使用自己的数据来训练 spaCy 的预训练模型。我们将通过训练一个命名实体识别NER)管道来实现这一点,但你也可以将同样的知识应用到预处理和训练 spaCy 管道的任何 NLP 任务上。在本章中,我们将更多地关注如何收集和标注自己的数据,因为我们已经在第六章中看到了如何使用 spaCy 的config.cfg文件来训练模型。

本章的学习之旅包括如何最大限度地利用来自 Explosion 的标注工具Prodigy以及 spaCy 背后的团队。我们还将看到如何使用 Jupyter Notebook 标注 NER 数据。之后,我们将使用这些标注数据更新 spaCy 管道的 NER 组件。

本章将带你完成整个机器学习实践,包括收集数据、标注数据和训练信息提取模型。

到本章结束时,你将准备好在自己的数据上训练 spaCy 模型。你将具备收集数据、将数据预处理成 spaCy 可以识别的格式,以及最终使用这些数据训练 spaCy 模型的全套技能。我们将涵盖以下主要主题:

  • 开始数据准备

  • 标注和准备数据

  • 训练 NER 管道组件

  • 在同一管道中结合多个 NER 组件

技术要求

本章的代码可以在github.com/PacktPublishing/Mastering-spaCy-Second-Edition找到。我们将使用nertk库通过 Jupyter 笔记本来标注 NER 数据。

开始数据准备

spaCy 开箱即用的模型在通用 NLP 方面非常成功,但有时我们必须处理需要定制训练的非常具体的领域。

训练模型需要时间和精力。在开始训练过程之前,你应该决定是否需要训练。为了确定你是否真的需要定制训练,一个很好的起点是问自己以下问题:

  • spaCy 模型在你的数据上表现足够好吗?

  • 你的领域是否包含许多在 spaCy 模型中缺失的标签?

  • Hugging Face Hub 或其他地方已经有了预训练的模型/应用程序吗?(我们不想重新发明轮子。)

让我们在以下章节中详细讨论这两个问题。

spaCy 模型在你的数据上表现足够好吗?

通常情况下,如果模型表现足够好(通常,准确率在 0.75 以上),那么你可以通过另一个 spaCy 组件来定制模型输出。例如,假设我们在导航领域工作,我们有以下这样的表述:

navigate to my home
navigate to Oxford Street

让我们看看 spaCy 的 NER 模型为这些句子输出了哪些实体:

import spacy
nlp = spacy.load("en_core_web_md")
doc1 = nlp("navigate to my home")
doc1.ents
 ()
doc2 = nlp("navigate to Oxford Street")
doc2.ents
 (Oxford Street,)
 doc2.ents[0].label_
'FAC'
spacy.explain("FAC")
'Buildings, airports, highways, bridges, etc.'

在这里,根本不被识别为实体,但我们希望它被识别为地点实体。此外,spaCy 的 NER 模型将牛津街标记为FAC,意味着建筑/公路/机场/桥梁类型的实体,这并不是我们想要的。

我们希望这个实体被识别为GPE,即一个地点。在这里,我们可以进一步训练 NER 以识别街道名称为GPE,以及识别一些地点词汇(如工作、家和我的妈妈家)为GPE

另一个例子是报纸领域。在这个领域,人物地点日期时间组织都是需要提取的目标实体,但你还需要一个额外的实体类型——车辆(汽车、公共汽车、飞机等)。因此,你不必从头开始训练,而是可以使用 spaCy 的SpanRuler(在第第四章中解释)添加一个新的实体类型。始终首先检查你的数据,并计算 spaCy 模型的成功率。如果成功率令人满意,那么可以使用其他 spaCy 组件进行定制。

你的领域是否包含许多在 spaCy 模型中不存在的标签?

例如,在先前的报纸示例中,只有车辆这个实体标签在 spaCy 的 NER 模型标签中缺失,但其他实体类型都被识别了。由于我们可以使用SpanRuler识别车辆实体,在这种情况下,你不需要定制训练。

再次考虑医疗领域。实体包括疾病、症状、药物、剂量、化合物名称等等。这是一份专业且长的实体列表。对于医疗领域,你需要进行定制模型训练。

如果我们需要定制模型训练,我们通常遵循以下步骤:

  1. 收集我们的数据。

  2. 标注我们的数据。

  3. 决定更新现有模型或从头开始训练一个模型。

在数据收集步骤中,我们决定收集多少数据:1,000 个句子、5,000 个句子,或者更多。数据量取决于我们任务的复杂性和领域。通常,我们从可接受的数据量开始,第一次训练模型,看看它的表现;然后,我们可以添加更多数据并重新训练模型。

在收集完数据集后,你需要以这种方式标注你的数据,使得 spaCy 的训练代码能够识别它。在下一节中,我们将看到训练数据格式以及如何使用 Explosion 的 Prodigy 工具标注数据。

第三步是决定从头开始训练一个空白模型,还是对现有模型进行更新。在这里,一个经验法则是:如果你的实体/标签存在于现有模型中,但你没有看到非常好的性能,那么就用你自己的数据更新模型,例如在先前的车辆示例中。如果你的实体在当前的 spaCy 模型中根本不存在,那么你很可能需要进行定制训练。

我们将开始构建模型的旅程,第一步是准备我们的训练数据。让我们继续到下一节,看看如何准备和标注我们的训练数据。

标注和准备数据

训练模型的第一步始终是准备训练数据。你通常从客户日志中收集数据,然后通过将数据作为 CSV 文件或 JSON 文件导出,将它们转换为数据集。spaCy 模型训练代码与DocBin对象一起工作,因此在本章中,我们将标注数据并将其转换为DocBin对象。

收集我们的数据后,我们标注我们的数据。标注意味着标记意图、实体、POS 标签等。

这是一个标注数据的示例,取自 spaCy 的在线评论中检测时尚品牌教程(可在github.com/explosion/projects/tree/v3/tutorials/ner_fashion_brands 获取):

{
  "text": "Bonobos has some long sizes.",
  "tokens": [
    { "text": "Bonobos", "start": 0, "end": 7, "id": 0 },
    { "text": "has", "start": 8, "end": 11, "id": 1 },
    { "text": "some", "start": 12, "end": 16, "id": 2 },
    { "text": "long", "start": 17, "end": 21, "id": 3 },
    { "text": "sizes", "start": 22, "end": 27, "id": 4 },
    { "text": ".", "start": 27, "end": 28, "id": 5 }
  ],
  "spans": [
    {
      "start": 0,
      "end": 7,
      "token_start": 0,
      "token_end": 0,
      "label": "FASHION_BRAND"
    }
  ]
}

我们标注数据的目的是指向统计算法我们想要模型学习的内容。在这个例子中,我们希望模型学习关于实体;因此,我们提供带有标注实体的示例。

在本节中,我们还将看到 spaCy 的标注工具Prodigy,以及nertk,一个简单的开源 Python 库,用于使用 Jupyter Notebook 标注 NER 数据。Prodigy 专为快速项目的小团队量身定制。尽管它不是免费或开源的,但购买 Prodigy 有助于资助开源项目,包括 spaCy 本身的发展。

使用 Prodigy 标注数据

Prodigy 是一个由主动学习驱动的现代工具。我们将使用Prodigy live demodemo.prodi.gy/)来展示标注工具的工作方式。

让我们开始吧:

  1. 我们导航到 Prodigy Live Demo,并查看 Prodigy 要标注的示例文本,如下截图所示:

图 8.1 – Prodigy 界面(从他们的 Prodigy 演示页面截取的截图)

图 8.1 – Prodigy 界面(从他们的 Prodigy 演示页面截取的截图)

这张截图,图 8.1,展示了我们想要标注的示例文本。截图底部的按钮展示了接受这个训练示例、拒绝它或忽略它的方法。如果文本相关且标注良好,我们就接受它,并将其加入我们的数据集。

  1. 接下来,我们将标注实体。为此,首先,我们从顶部栏中选择一个实体类型。这个语料库包括两种类型的实体,PERSONORG。然后,我们将用光标选择我们想要标注为实体的单词,如下截图所示:

图 8.2 – 在 Prodigy 演示页面上标注 PERSON 实体

图 8.2 – 在 Prodigy 演示页面上标注 PERSON 实体

  1. 在我们完成文本标注后,我们点击接受按钮。一旦会话结束,你可以将标注的数据导出为 JSON 文件。

Prodigy 的功能不仅包括命名实体识别(NER),还包括文本分类、计算机视觉、提示工程、大型语言模型等。

使用 nertk 标注数据

另一个标注工具是nertk,它是一个开源的 Python 库,用于 NER 文本标注(github.com/johnsmithm/nertk)。该工具在 Jupyter Notebook 中在线工作,允许你快速轻松地与数据交互并进行标注。该项目目前处于 alpha 开发阶段,因此它仅适用于简单和快速的 NER 标注会话。

要使用nertk,你需要使用pip install nertk安装它,运行 Jupyter Notebook 服务器,并在网页浏览器中打开服务器的 URL。为了处理nertk的数据,你需要对文本进行分词并传递你想要标注的数据。让我们一步一步来做:

  1. 首先,我们需要对数据进行分词,因为nertk与单词列表一起工作。我们这一章的数据集是葡萄牙语,所以我们导入en模型并仅选择tokenizer组件,因为这是我们唯一需要的:

    import spacy
    nlp = spacy.load("en_core_web_sm", enable="tokenizer")
    
  2. 我们将使用 spacy 的在线评论中检测时尚品牌教程的数据集。原始文本来自r/MaleFashionAdvicer/FemaleFashionAdvice子版块。数据是jsonl格式,因此我们将使用srsly库来读取它。让我们打印第一个条目来看看数据的样子:

    import srsly
    from pprint import pprint
    training_path = "data/fashion_brands_training.jsonl"
    for row in srsly.read_jsonl(training_path):
        pprint(row)
        break
    

    由于数据已经用 Prodigy 进行了标注,它具有['text', 'meta', '_input_hash', '_task_hash', 'tokens', 'spans', '_session_id', '_view_id', 'answer']键。为了了解如果没有实体我们会怎么做,让我们只使用text键,它包含 Reddit 评论的文本。

  3. 下一步是对数据进行分词。我们需要创建一个列表,其中每个项目也是一个列表,但它是评论单词的列表。为此,我们将遍历数据,使用我们之前加载的模型对其进行分词,并将其添加到主列表中:

    nertk_input_text = []
    for row in srsly.read_jsonl(training_path):
        comment = nlp(row["text"])
        comment_words = [token.text for token in comment]
        nertk_input_text.append(comment_words)
    
  4. 现在,我们已经准备好开始使用nertk工具。我们需要将实体标签名称的列表和每个评论的单词列表传递给Entator类。然后,我们可以调用run()方法开始标注:

    from nertk import Entator
    annotator = Entator(labels=['None', 'FASHION_BRAND'],
    inputs=nertk_input_text)
    annotator.run()
    

    我们将标注所有的FASHION_BRAND实体。图 8.3 .3显示了nertk界面:

图 8.3 – nertk 界面

图 8.3 – nertk 界面

要选择属于FASHION_BRAND实体的标记,你只需点击它们。当你准备好示例后,你可以点击下一个按钮来标注另一个。标注的标记结果存储在annotator.targets属性中。

将标注的数据转换为 DocBin 对象

要使用此注释数据来训练模型,我们需要将其转换为DocBin格式。为此,我们将为每个注释创建一个Doc对象,并添加我们已注释的实体。让我们创建执行此操作的代码:

  1. 首件事是创建DocBin对象,并遍历每个注释以获取注释实体的索引:

    from spacy.tokens import DocBin, Span
    from utils import create_consecutive_token_sequences
    db = DocBin()
    for idx, (row, nerkt_tokens, nertk_entities) in enumerate(
        zip(srsly.read_jsonl(training_path), nertk_input_text,
            annotator.targets)):
        if idx == 5:
            break
        doc = nlp(row["text"])
        indexes_entity_tokens = [index for index, x in enumerate(
            nertk_entities) if x == "FASHION_BRAND"]
    
  2. 使用nertk,我们注释了每个标记,但实体可以包含多个标记。为了处理这种情况,我们使用一个辅助函数,该函数获取indexes_entity_tokens列表,并为每个实体的起始和结束标记的索引创建元组(此代码仍然位于主for循环内):

    span_indexes = create_consecutive_token_sequences(indexes_entity_tokens)
    
  3. 现在,是时候为每个实体创建Span对象了。我们创建一个ents列表来存储这些跨度,将此列表设置为doc.ents参数,并将此Doc添加到DocBin对象中(此代码仍然位于主for循环内):

      ents = []
      label = "FASHION_BRAND"
      for start,end in span_indexes:
          span = Span(doc, start, end+1, label)
          ents.append(span)
      doc.ents = ents
      db.add(doc)
    
  4. 最后,我们将DocBin文件保存到磁盘:

    db.to_disk("data/nertk_training.spacy")
    

    这里是将nertk注释的实体转换为DocBin的完整代码:

    from spacy.tokens import DocBin, Span
    from utils import create_consecutive_token_sequences
    db = DocBin()
    for idx, (row, nerkt_tokens, nertk_entities) in enumerate(
        zip(srsly.read_jsonl(training_path), nertk_input_text,
            annotator.targets)):
        if idx == 5:
            break
        doc = nlp(row["text"])
        indexes_entity_tokens = [index for index, x in enumerate(
            nertk_entities) if x == "FASHION_BRAND"]
    
        span_indexes = create_consecutive_token_sequences(
            indexes_entity_tokens)
    
        ents = []
        label = "FASHION_BRAND"
        for start, end in span_indexes:
            span = Span(doc, start, end + 1, label)
            ents.append(span)
    
        doc.ents = ents
        db.add(doc)
    db.to_disk("data/nertk_training.spacy")
    

这是创建用于微调 spaCy 组件的ner的训练数据的流程。要为任何其他可训练组件创建训练数据,流程是相同的。你创建Doc对象,使用适当的格式设置注释,然后将这些 Docs 保存为DocBin对象。你可以在这里检查所有数据格式:spacy.io/api/data-formats#dict-input

在训练数据集之后,下一步是训练ner组件。让我们在下一节中完成这项工作。

训练 NER 管道组件

我们的目标是创建一个用于识别FASHION_BRAND实体的管道。我们将通过训练EntityRecognizer管道组件来实现这一点。它使用基于转换的算法,该算法假设关于接近初始标记的实体的最关键信息。

训练组件的第一件事是创建config文件。我们将使用spacy init config CLI 命令来完成此操作:

python3 –m spacy init config cpu_config.cfg --lang "en" --pipeline "ner" --optimize "efficiency"

这将创建包含所有默认配置的cpu_config.cfg文件,并将ner组件设置为针对效率(更快推理、更小的模型和更低的内存消耗)进行训练优化。我们将使用 spaCy 教程中的preprocess.py脚本来将jsonl数据转换为DocBin对象:

python3 ./scripts/preprocess.py ./data/fashion_brands_training.jsonl ./data/fashion_brands_training.spacy
python3 ./scripts/preprocess.py ./data/fashion_brands_eval.jsonl ./data/fashion_brands_eval.spacy

现在,我们准备好使用spacy train CLI 命令来训练模型:

python3 -m spacy train cpu_config.cfg --output training_cpu/ --paths.train ./data/fashion_brands_training.spacy --paths.dev ./data/fashion_brands_eval.spacy

output参数指定了存储训练管道的目录。在图 8 .4中,我们可以看到训练过程中产生的消息。

图 8.4 – ner 组件训练过程中产生的消息

图 8.4 – ner 组件训练过程中产生的消息

我们可以看到,在训练过程中,有一个tok2vec组件和ner组件的训练损失。这是因为ner组件默认使用spacy.TransitionBasedParser.v2模型架构。正如 spaCy 文档所说(spacy.io/api/architectures#TransitionBasedParser),模型由两个或三个子网络组成。tok2vec组件将每个标记转换为一个向量,每次批量运行一次。Lower为每个批次的**(标记,特征)**对生成一个向量,结合特征并应用非线性以形成状态表示。Upper是一个可选的前馈网络,它从状态表示中推导出分数。如果省略,则直接使用底层模型的输出作为动作分数。总之,tok2vec组件负责将标记映射到向量表示。

评估 NER 组件的精度

我们可以使用spacy evaluate CLI 命令来评估管道的精度。该命令期望一个可加载的 spaCy 管道和DocBin格式的评估数据。我们没有测试数据集,所以我们将使用fashion_brands_eval.spacy数据集只是为了看看命令是如何工作的。由于数据集也被用来决定何时停止模型训练,所以结果会被高估。这被称为数据泄露。然而,在这里,我们是为了学习目的而训练模型,所以我们没问题。让我们运行命令并检查结果:

python3 -m spacy evaluate training_cpu/model-best ./data/fashion_brands_eval.spacy --output training_cpu/metrics.json

命令会显示输出并将指标保存到training_cpu/metrics.json文件中。图 8.5显示了结果:

图 8.5 – ner 模型的精度

图 8.5 – ner 模型的精度

精度得分为76.88,召回率为55.88。这是一个在 CPU 上训练的模型;如果我们用 GPU 训练它并使用 transformers 会怎样呢?spaCy 使尝试这一点变得简单;我们只需要创建一个新的config.cfg文件,然后再次运行spacy train命令。让我们在下一节中这样做。

训练一个针对精度优化的 NER 组件

训练一个针对精度优化且在 GPU 上运行的组件需要您安装spacy-transformers库。之后,我们可以创建新的配置文件,如下所示:

python3 -m spacy init config gpu_config.cfg -l "en" -p "ner" --optimize "accuracy" --gpu

然后,我们可以再次训练模型,这次指向这个gpu_config.cfg文件:

python3 -m spacy train gpu_config.cfg --output training_gpu/ --paths.train ./data/fashion_brands_training.spacy --paths.dev ./data/fashion_brands_eval.spacy --gpu-id 0

我的机器有一个 GPU,所以我使用**–gpu-id 1**参数来设置它。当我们为了精度而训练时,通常模型的表现会更好,但我们也可能得到一个更大、更慢的模型。图 8.6显示了训练过程中产生的消息:

图 8.6 – 训练 NER 组件以实现精度时产生的消息

图 8.6 – 训练 NER 组件以实现精度时产生的消息

这种架构使用了一个 transformer Model 组件而不是tok2vec组件。我们可以看到,在每个评估点,这个管道的分数都更高,始终高于0.78。让我们再次运行评估来比较模型:

python3 -m spacy evaluate training_gpu/model-best ./data/fashion_brands_eval.spacy --output training_gpu/metrics.json --gpu-id 0

命令显示输出并将指标保存到training_gpu/metrics.json文件。图 8.7显示了结果:

图 8.7 – 为准确性训练的 ner 模型的准确性

图 8.7 – 为准确性训练的 ner 模型的准确性

准确率为88.79,召回率为79.83。如果我们将其与为效率训练的模型的76.8855.88结果进行比较,这是一个显著的增加。我们可以通过将训练期间创建的目录传递给nlp.load()方法来加载模型以供使用。让我们获取句子Givenchy is looking at buying U.K. startup for $** **1 billion 的实体:

  1. 首先,我们加载库和管道:

    import spacy
    from spacy import displacy
    nlp = spacy.load('training_gpu/model-best')
    
  2. 现在,我们可以将管道应用于文本:

    sentence = "Givenchy is looking at buying U.K. startup for $1 billion"
    doc = nlp(sentence)
    displacy.render(doc, style="ent", jupyter=True)
    

    结果显示在图 8.8;管道能够找到Givenchy实体:

图 8.8 – 使用为准确性训练的 ner 管道提取的实体

图 8.8 – 使用为准确性训练的 ner 管道提取的实体

该组件只能识别FASHION_BRAND实体,这是我们唯一为其训练的实体。但 spaCy 提供的en_core_web_sm模型可以识别更多实体。让我们来看看:

  1. 加载库和模型:

    import spacy
    from spacy import displacy
    nlp = spacy.load('en_core_web_sm')
    
  2. 将管道应用于文本:

    sentence = "Givenchy is looking at buying U.K. startup for $1 billion"
    doc = nlp(sentence)
    displacy.render(doc, style="ent", jupyter=True)
    

    结果显示在图 8.9;管道能够找到GPEMONEY实体:

图 8.9 – 使用 en_core_web_sm 管道提取的实体

图 8.9 – 使用 en_core_web_sm 管道提取的实体

如果我们想要提取我们训练过的实体以及en_core_web_sm NER 组件训练过的实体呢?我们可以通过在同一个管道中结合多个 NER 组件来实现这一点。

在同一个管道中结合多个 NER 组件

当 spaCy 加载一个管道时,它会遍历管道名称,并在[components]块中查找每个组件名称。组件可以通过factorysource来构建。Factories是命名函数,接受设置并返回一个管道组件函数,而source用于引用一个训练管道的路径名称,以便从中复制组件。

要使用我们训练过的 NER 组件以及en_core_web_sm的 NER 组件,我们将创建一个新的config.cfg文件,并在管道中引用 NER 组件。使用这个config.cfg文件设置后,我们将使用spacy assemble命令来组装配置,而无需额外的训练。为了能够使用spacy assemble引用训练过的 NER 组件,我们将为管道创建一个包,并使用pip install安装它。

为训练的管道创建包

spacy 包 CLI 命令从现有的管道目录生成一个可安装的 Python 包。spaCy 创建了一个可以与pip install一起安装的构建工件。如果您想在生产环境中部署您的管道或与他人共享,这很有用。

我们将提供包含管道数据的目录路径,创建包文件夹的目录路径以及包名称。这是完整的命令:

python3 -m spacy package training_gpu/model-best ./ --name "ner_fashion_brands"

命令创建了一个名为en_ner_fashion_brands-0.0.0的文件夹。我们现在可以安装这个包并在 Python 中加载它:

  1. 安装包:

    python3 -m pip install en_ner_fashion_brands-0.0.0/
    
  2. 导入包,加载管道,并将其应用于文本:

    import en_ner_fashion_brands
    from spacy import displacy
    nlp = en_ner_fashion_brands.load()
    sentence = "Givenchy is looking at buying U.K. startup for $1 billion"
    doc = nlp(sentence)
    displacy.render(doc, style="ent", jupyter=True)
    

它的工作方式就像从其目录中加载管道一样。现在,我们可以在新的config文件中指向这个管道的 NER 组件,以创建一个使用此 NER 组件和en_core_web_sm NER 组件的管道。

创建具有不同 NER 组件的管道

spacy assemble CLI 命令从config文件组装管道,无需额外训练。就这么简单。这个config文件更简单,因为它仅用于构建管道,而不是用于训练。让我们创建combined_ner.cfg文件:

  1. 首先,我们定义nlp对象。我们将指定管道语言 ISO 代码和管道组件的名称顺序。我们将在[components]部分指定如何加载它们,这样我们就可以按我们的意愿命名它们:

    [nlp]
    lang = "en"
    pipeline = ["ner_fashion_brands","ner"]
    
  2. 由于我们想要从现有管道中复制组件,我们将设置每个组件的source参数。参数值可以是可加载的 spaCy 管道包或路径。定义后,我们设置component参数,这是source管道中组件的名称。让我们首先定义如何加载en_core_web_sm管道的ner组件:

    [components]
    [components.ner]
    source = "en_core_web_sm"
    component = "ner"
    
  3. 现在,为了加载我们训练来识别时尚品牌的ner组件,我们还需要使用replace_listeners参数包括source管道的tok2vec监听器的副本,如下所示:

    [components.ner_fashion_brands]
    source = "en_ner_fashion_brands"
    component = "ner"
    replace_listeners = ["model.tok2vec"]
    

现在,我们有一个用于组装最终管道的config文件。我们将在下一步做这件事。

从配置文件组装管道

要组装管道,我们将提供我们创建的combined_ner.cfg文件和存储最终管道的输出目录:

python3 -m spacy assemble combined_ner.cfg pipelines/fashion_ner_with_base_entities

如果一切正常工作,我们现在可以加载这个管道,它将识别出两个ner组件的所有实体。让我们看看这是否是情况:

  1. 让我们先加载我们组装的管道:

    import spacy
    from spacy import displacy
    nlp = spacy.load("pipelines/fashion_ner_with_base_entities")
    
  2. 现在,让我们处理文本并使用displacy显示实体:

    sentence = "Givenchy is looking at buying U.K. startup for $1 billion"
    doc = nlp(sentence)
    displacy.render(doc, style="ent", jupyter=True)
    

    我们可以在图 8.10中看到结果:

图 8.10 – 使用组装的管道识别的实体 #TODO:获取新的截图

图 8.10 – 使用组装的管道识别的实体 #TODO:获取新的截图

真是 neat,对吧?我们可以组合我们需要的任意数量的ner组件。

摘要

在本章中,我们探讨了如何使用我们自己的领域和数据来训练 spaCy NER 组件。首先,我们学习了决定我们是否真的需要自定义模型训练的关键点。然后,我们了解了模型训练的一个关键部分——数据收集和标注。

我们了解了两种标注工具——Prodigy 和 nertk——并学习了如何将数据转换为训练格式,以及如何使用 spaCy 的 CLI 来训练组件。接着,我们使用 spaCy CLI 命令来训练组件并创建用于管道的 Python 包。

最后,我们学习了如何将不同的 NER 组件组合成一个单一的管道。在下一章中,我们将学习如何使用 spaCy 和 Weasel 管理和共享针对不同用例和领域的端到端工作流程。

第九章:使用 Weasel 创建端到端 spaCy 工作流程

在本章中,我们将探讨如何使用 spaCy 及其配套工具Weasel创建端到端 NLP 工作流程。最初是 spaCy 的一部分,Weasel 现在已成为一个独立的库,这意味着您也可以将其用于不是用 spaCy 创建的其他项目。

数据版本控制DVC)提供了一套针对数据/模型版本控制和实验跟踪的解决方案,增强了协作和实验管理。通过将 Weasel 与 DVC 集成,我们确保我们的项目能够高效地进行版本控制和跟踪,提高组织和可靠性。

在本章中,我们将首先使用 Weasel 克隆和运行项目模板,遵循最佳软件工程实践以确保可重复和结构良好的工作流程。然后我们将为此模板适应不同的用例,最后我们将探讨如何使用 DVC 跟踪和管理训练好的模型,从而实现高效的协作。这种方法将使我们能够创建健壮的 NLP 管道,为生产和工作团队做好准备。

我们将涵盖以下主要主题:

  • 使用 Weasel 克隆和运行项目模板

  • 为不同用例修改项目模板

  • 使用 DVC Studio 模型注册管理模型

技术要求

在本章中,我们将使用 spaCy、Weasel 和 DVC 库。本章代码可以在github.com/PacktPublishing/Mastering-spaCy-Second-Edition找到。

使用 Weasel 克隆和运行项目模板

spacy weasel clone命令从 Git 仓库克隆一个项目模板。默认情况下,它使用 spaCy 的项目模板仓库(github.com/explosion/projects),但您可以使用--repo选项提供您有权访问的任何其他仓库(公开或私有)。

在本章中,我们将使用Reddit 帖子情感分类(也称为文本分类)项目作为我们的项目模板。让我们继续并克隆项目:

python3 -m weasel clone tutorials/textcat_goemotions

此命令在当前目录中创建一个textcat_goemotions文件夹。project.yml文件定义了与项目相关的所有内容。这包括资产和自定义命令。project.yml文件的主要部分如下:

  • title:没错,这定义了项目的标题。

  • description:可选的项目描述。

  • vars:一个包含路径、URL 和脚本的变量字典,可以通过 CLI 进行覆盖。

  • env:一个将变量映射到环境名称的字典,允许项目脚本使用环境变量中的值,例如${env.name}

  • directories:要在项目中创建的目录列表,如果不存在,则由 spaCy 自动生成。

  • 资产:要获取的资产列表,每个资产由一个 URL 或本地路径、项目内的目标位置以及可选的校验和定义。您还可以通过指定repobranchpath来指定 Git 仓库,直接从 Git 下载资产。

  • 命令:命令指定了如何运行项目步骤。它通常指定如何运行 Python 脚本。每个命令都使用scriptdepsoutputs指定。deps定义了命令所依赖的文件,而outputs定义了命令生成的文件。这允许 spaCy 确定何时重新运行命令,如果依赖项已更改。

  • 工作流程:工作流程定义了一个应该按顺序执行的命令列表。

现在我们已经克隆了模板,下一步是获取资产。spacy weasel assets命令下载project.ymlassets部分定义的所有资产。让我们下载这些资产:

cd ./textcat_goemotions
python3 -m weasel assets

这个项目有四个资产:一个包含训练类别的文件和三个数据文件,一个用于训练,一个用于开发,一个用于测试。数据是.tsv格式,因此我们需要将其转换为.spacy二进制格式。scripts/convert_corpus.py脚本执行这个操作。project.yml文件中有一个名为preprocess的命令,我们可以使用它来运行这个脚本。让我们看看这个命令是如何定义的:

commands:
  - name: preprocess
    help: "Convert the corpus to spaCy's format"
    script:
      - "python scripts/convert_corpus.py"
    deps:
      - "assets/train.tsv"
      - "assets/dev.tsv"
      - "assets/test.tsv"
      - "assets/categories.txt"
    outputs:
      - "corpus/train.spacy"
      - "corpus/dev.spacy"
      - "corpus/test.spacy"

命令指定了deps键中的资产(因为我们需要它们来运行代码)以及代码生成的输出。要运行此命令,我们可以使用spacy project run和命令名称,例如:spacy project run preprocess。此命令在corpus文件夹内创建train.spacydev.spacytest.spacy

all工作流程按顺序调用preprocesstrainevaluatepackage命令。这样很有用,因为我们不需要手动调用每个命令。让我们尝试运行这个工作流程:

python3 -m weasel run all

图 9 .1显示了命令输出。Weasel 验证了preprocess命令的deps没有更改,因此跳过了这一步。

图 9.1 – 运行所有工作流程后的输出

图 9.1 – 运行所有工作流程后的输出

visualize命令使用Streamlitspacy-streamlit来提供用于与模型交互的 Web 应用程序。我们可以使用weasel run visualize运行此命令。图 9 .2显示了 Web 应用程序界面。我们将在第十一章中学习如何使用 spaCy 与 Streamlit 一起使用。

图 9.2 – 与模型交互的 Streamlit 界面

图 9.2 – 与模型交互的 Streamlit 界面

所有这些,我们已经成功使用项目模板来重新创建这个文本分类流程。如果我们有另一个数据集并且需要执行这些相同的项目步骤怎么办?我们可以简单地重用模板并调整以满足我们的需求。让我们在下一节中学习如何做到这一点。

修改项目模板以适应不同的用例

要为不同的用例重用项目模板,我们首先会克隆项目,指定一个不同的dest文件夹。让我们这样做:

cd ..
python3 -m weasel clone tutorials/textcat_goemotions textcat_github_issues

这将创建一个textcat_github_issues文件夹。新的用例使用来自Prodigy的标注数据,我们应该预测 GitHub 问题标题是否关于文档。原始项目在此处可用:github.com/explosion/projects/tree/v3/tutorials/textcat_docs_issues。本节的目标是学习如何重用项目模板,因此我们将修改textcat_goemotions项目以适应这个领域。

首先,我们应该使用这个新 GitHub 问题项目的信息更新titledescriptionvars。GitHub 问题的资产以.jsonl格式存在,因此我们需要修改命令以将数据转换为.spacy格式。我们有三个文件:train.jsonldev.jsonleval.jsonl。让我们首先修改project.yml中的 assets 键,使其指向这些文件:

assets:
  - dest: "assets/train.jsonl"
    url: "https://raw.githubusercontent.com/PacktPublishing/Mastering-spaCy-Second-Edition/refs/heads/main/chapter_09/data/train.jsonl"
    description: "JSONL-formatted training data exported from Prodigy, annotated with `DOCUMENTATION` (661 examples)"
  - dest: "assets/dev.jsonl"
    url: "https://raw.githubusercontent.com/PacktPublishing/Mastering-spaCy-Second-Edition/refs/heads/main/chapter_09/data/dev.jsonl"
    description: "JSONL-formatted development data exported from Prodigy, annotated with `DOCUMENTATION` (500 examples)"
  - dest: "assets/test.jsonl"
    url: "https://raw.githubusercontent.com/PacktPublishing/Mastering-spaCy-Second-Edition/refs/heads/main/chapter_09/data/eval.jsonl"
    description: "JSONL-formatted test data generated with GPT-3.5 (300 examples)"

现在,我们可以使用assets命令下载资产:

cd textcat_github_issues
python3 -m weasel assets

让我们看看训练数据的一个片段:

{"text":"add please","cats":{"DOCUMENTATION":0.0,"OTHER":1.0}}
{"text":"Examples, failed to load qml","cats":{"DOCUMENTATION":0.0,"OTHER":1.0}}
{"text":"DMCHMM","cats":{"DOCUMENTATION":0.0,"OTHER":1.0}}
{"text":"Moving from MySQL to Hybrid SQL","cats":{"DOCUMENTATION":0.0,"OTHER":1.0}}

这种格式与textcat_goemotions不同,因此我们需要创建一个不同的convert_corpus.py脚本。我们将使用srsly(一个捆绑了一些最好的 Python 序列化库的包)来读取.jsonl数据,并使用typer(一个用于构建 CLI 应用程序的库)来指定脚本的参数。让我们编写一个脚本来完成这个任务:

  1. 首先,我们导入库并定义目录的变量:

    import srsly
    import typer
    import spacy
    from spacy.tokens import DocBin
    from pathlib import Path
    ASSETS_DIR = Path(__file__).parent.parent / "assets"
    CORPUS_DIR = Path(__file__).parent.parent / "corpus"
    
  2. 要运行脚本,我们应该指定读取资产的目录、保存语料的目录以及我们将用于创建Doc对象的模型的语言。使用typer,我们可以通过创建一个具有这些参数的函数来完成此操作:

    def main(assets_dir: Path=ASSETS_DIR, 
             corpus_dir: Path=CORPUS_DIR, lang: str="en"):
    
  3. 脚本应该转换所有的train.jsonldev.jsoneval.jsonl文件,因此我们需要遍历assets_dir中的每个文件,如果它是一个.jsonl文件,我们将创建一个DocBin对象:

    nlp = spacy.blank(lang)
    for jsonl_file in assets_dir.iterdir():
        if not jsonl_file.parts[-1].endswith(".jsonl"):
            continue
        db = DocBin()
    
  4. 对于每个jsonl_file,我们将构建Doc对象,设置cats参数,并将其添加到DocBin对象中。最后,我们将此DocBin对象保存到磁盘:

    for line in srsly.read_jsonl(jsonl_file):
        doc = nlp.make_doc(line["text"])
        doc.cats = line["cats"]
        db.add(doc)
    out_file = corpus_dir / jsonl_file.with_suffix(
        ".spacy").parts[-1]
    db.to_disk(out_file)
    
  5. 最后一步是将此脚本与 Typer 绑定。我们通过typer.run(main)来完成:

    if __name__ == "__main__":
        typer.run(main)
    
  6. 现在,我们可以保存这个新的convert_corpus.py脚本并运行以下命令:

    python3 -m weasel run preprocess
    

    图 9 .3显示了此命令的输出。

图 9.3 – 运行 preprocess 命令后的 Weasel 输出

图 9.3 – 运行 preprocess 命令后的 Weasel 输出

Weasel 表示我们缺少assets/train.tsv依赖项。这是因为我们没有更新preprocess命令的depsoutputs以适应这个新用例。让我们现在做这件事:

  - name: preprocess
    help: "Convert the corpus to spaCy's format"
    script:
      - "python scripts/convert_corpus.py"
    deps:
      - "assets/train.jsonl"
      - "assets/dev.jsonl"
      - "assets/test.jsonl"
    outputs_no_cache:
      - "corpus/train.spacy"
      - "corpus/dev.spacy"
      - "corpus/test.spacy"
  1. 现在您可以保存这些更改并再次尝试运行preprocess命令。该脚本在/corpus目录中创建.spacy文件。好的,让我们也更新evaluate命令的依赖项并运行all工作流程来训练、评估并为这个 GitHub 问题项目创建一个包:

    python3 -m weasel run all
    

    图 9 .4 显示了此管道的评估结果。

图 9.4 – 评估结果

图 9.4 – 评估结果

我们能否使用 BERT 获得更好的结果?让我们在project.ymlvars部分中更改这一点:

vars:
  name: "textcat_github_issues"
  version: "0.0.1"
  # Choose your GPU here
  gpu_id: 0
  # Change this to "bert" to use the transformer-based model
  config: "bert"

我在configs/bert.cfg文件上定义的批处理配置运行训练时遇到了麻烦,所以让我们将其更改为spacy.batch_by_padded.v1

[training.batcher]
@batchers = "spacy.batch_by_padded.v1"
discard_oversize = true
size = 2000
buffer = 256
get_length = null

现在我们可以再次运行工作流程:

python3 -m weasel run all

图 9 .5 显示了此模型的评估结果,其性能优于第一个。

图 9.5 – 使用 BERT 的结果

图 9.5 – 使用 BERT 的结果

现在我们有了训练好的模型,我们可以使用 Weasel 将它们上传到远程存储。让我们在下一节中这样做。

上传和下载项目输出到远程存储

您可以使用spacy project push命令将项目输出存储在远程存储中,这样您就可以共享管道包、与团队协作或缓存结果以避免重复任务。spacy project pull命令从远程存储检索任何缺失的输出。远程存储在project.ymlremotes部分中指定。要配置远程存储,您可以在project.yml文件的remotes部分中列出一个或多个目的地,将字符串名称映射到存储 URL。spaCy 使用cloudpathlib与远程存储通信,允许使用cloudpathlib支持的任何协议,包括 S3、Google Cloud Storage、Azure Blob Storage 和本地文件系统。

push命令将命令中outputs部分列出的所有文件或目录上传到远程存储。上传前,输出会被归档和压缩。Weasel 使用命令字符串和依赖项的哈希以及文件内容的哈希。这意味着push永远不会覆盖远程存储中的文件。如果所有哈希匹配,内容相同,则不会发生任何操作。如果内容不同,则上传新版本的文件。

pull命令下载命令中列出的所有文件或目录,除非它们已经存在于本地。在远程存储中搜索文件时,pull将考虑输出路径、命令字符串和依赖项哈希。

我们为 GitHub 问题用例训练了两个模型。如果我们处于生产环境中,我们需要管理这些训练好的模型。最直接的应用场景是在生产环境中在这两个模型之间切换。DVC 是一个库,它帮助我们通过管道连接到版本化的数据源和代码,跟踪实验,并注册模型。Weasel 受到了 DVC 的启发,我们可以使用spacy weasel dvc命令来自动生成一个 DVC 配置文件。有了这个文件,我们可以像管理其他 DVC 项目一样管理 spaCy 项目。在下一节中,我们将使用 DVC Studio 模型注册表来编目我们的机器学习ML)模型,并在生产环境中管理它们。

使用 DVC 模型注册表管理模型

DVC 是一个开源的命令行工具,帮助我们开发可重复的机器学习项目。Weasel 本身受到了 DVC 的启发(x.com/honnibal/status/1316792615996592133)。它包含版本化数据和模型、跟踪实验、比较数据和共享实验的工具。在本节中,我们将使用 DVC Studio 中的模型注册表,这是一个网络应用程序,它使团队能够运行和跟踪实验,并管理模型的生命周期。

在底层,DVC Studio 使用一个名为Git Tag OpsGTO)的命令行来进行模型注册操作。要使用 DVC,了解更多的GitOps很有用。让我们在下一节中这样做。

什么是 GitOps?

GitOps是一组强调使用 Git 版本控制系统作为声明性基础设施和应用程序的真相来源的实践。它借鉴了 DevOps 和基础设施即代码实践的思想。GitOps WGopengitops.dev/)定义了四个 GitOps 原则(v1.0.0):

  • 声明式:在 GitOps 管理的系统中,所需状态必须以声明性方式定义

  • 版本化和不可变:所需状态以强制不可变和版本化的方式存储,并保留完整的版本历史

  • 自动拉取:软件代理自动从源拉取所需状态声明

  • 持续协调:软件代理持续监控系统,并努力使其与所需状态保持一致

让我们更深入地探讨以下列表中的每个原则:

  • 原则 1 – 声明式:在实践中,这意味着你的基础设施和应用程序的所有配置和设置都描述在声明性格式中,例如 YAML 或 JSON 文件。而不是编写执行一系列步骤以达到所需状态的脚本,你定义最终状态应该是什么样子。这也是Weaselproject.yml文件的原则——它确保我们没有隐藏的默认值。配置文件充当了如何配置你的系统的蓝图。

  • 原则 2 – 版本化和不可变:这一原则强调所有配置文件(声明性描述)应存储在 Git 仓库中。Git 本身支持版本控制,允许您跟踪随时间的变化。不可变性意味着一旦定义并提交了特定状态,就不应对其进行更改。如果需要更改,应创建一个新的版本。这种做法确保您有配置所做每个更改的完整历史记录,使得理解代码和基础设施的演变以及必要时回滚到先前状态变得更容易。例如,如果新的配置破坏了您的应用程序,您可以使用 Git 的版本历史快速回滚到最后已知的好配置。

  • 原则 3 – 自动拉取:在 GitOps 设置中,当检测到新的提交时,GitOps 代理应自动拉取更新的配置文件并将更改应用到基础设施或应用程序。这种自动化确保了 Git 仓库中进行的任何更新都能及时反映在实际运行环境中,无需人工干预。

  • 原则 4 – 持续协调:GitOps 代理应持续比较您基础设施和应用程序的实际状态与 Git 仓库中定义的期望状态。如果两者之间存在差异,代理将尝试通过进行必要的调整来使实际状态与期望状态一致。这种持续协调确保您的系统始终与 Git 中的配置保持一致。这一原则确保了一致性和可靠性,防止了 Git 中声明的内容与您环境中实际运行的内容之间的偏差。

让我们继续讨论 DVC,接下来将解决常见的数据科学和机器学习挑战。

DVC 如何解决常见的数据科学和机器学习挑战

数据科学和机器学习项目经常面临可以使用 DVC 和 GitOps 原则有效解决的问题。以下是一些常见挑战以及这些工具和原则如何帮助解决它们:

  • 困难的数据共享和协作:在团队成员之间共享数据集、模型和实验可能很复杂,尤其是在处理大文件或多个版本时,会导致重复工作和错误。使用 DVC,我们可以使用远程存储来跟踪数据集和模型,就像我们使用 Git 跟踪代码一样。这允许团队成员拉取和推送资产,以确保每个人都在使用相同的状态。

  • 管道不可靠或不可重复:我们通常在 Jupyter 笔记本中开始我们的项目实验。这对于数据分析和原型设计是完全可行的,但当项目增长(我们希望如此)时,结构和自动化变得有益。结构化管道的一些要求包括将代码单元作为.py模块,并在专用文件中管理配置(以跟踪参数、文件路径等)。DVC(和 Weasel)为我们完美地创建了一个结构,以便我们可以创建这些可重用的管道。

  • 模型指标跟踪:保留模型性能指标的历史记录对于理解模型随时间演化和有效性至关重要。DVC 允许存储和版本控制性能指标,与模型一起,便于比较不同版本和分析改进。通过 GitOps,这些指标可以集成到 CI/CD 管道中,确保在部署之前,新模型版本始终与旧版本进行验证。

通过使用 DVC 和 GitOps 原则解决这些挑战,数据科学和机器学习团队可以实现更可靠、可重复和可扩展的工作流程。实施 GitOps 原则的好处包括以下内容:

  • 提高开发人员和运营效率

  • 提高开发者体验

  • 提高稳定性

  • 一致性和标准化

DVC 与 GitOps 实践无缝集成,因此通过使用此工具,我们可以获得所有这些好处。既然我们已经了解了 GitOps 是什么以及它所解决的挑战,那么我们就继续将我们的 Weasel 项目转换为 DVC,并将我们的模型添加到模型注册表中,以便我们可以共享它们。

从 Weasel 到 DVC

第一步是安装 DVC。DVC 是一个 Python 库,因此我们可以使用pipconda进行安装:

python3 -m pip install dvc dvc_gdrive

现在,在项目目录内,我们可以运行dvc init来初始化一个 DVC 项目。在这个目录中必须初始化一个 Git 仓库。让我们初始化项目:

python3 -m dvc init
git commit -m "First commit"

dvc init命令创建三个文件:.dvc/config.dvc/.gitignore,和.dvcignore。DVC 提供了对外部存储位置的访问,使我们能够管理和共享我们的数据和机器学习模型。它支持云提供商,如 Amazon S3、Microsoft Azure Blob Storage 和 Google Cloud Storage,以及自托管/本地选项,如 SSH 和 HDFS。在本章中,我们将使用 Google Drive 作为远程存储。为此,我们需要 Google Drive 文件夹的文件夹 ID。dvc remote add命令将远程配置添加到.dvc/config文件。让我们来做这件事:

python3 -m dvc remote add --default myremote gdrive:///path/to/folder
python3 -m dvc remote modify myremote gdrive_acknowledge_abuse true

gdrive_acknowledge_abuse标志允许下载被标记为可能具有滥用行为的文件,例如包含恶意软件或个人信息,但只有当此参数启用时,文件的所有者才能下载。现在,当我们运行dvc pulldvc push以使用 Google Drive 作为远程存储来存储或检索模型工件时,浏览器将打开一个新窗口进行身份验证。

一旦配置了 DVC,spacy project dvc 命令会自动从你的 project.yml 文件中生成一个 dvc.yaml 文件。这让你可以使用在 project.yml 中定义的工作流程来管理你的 spaCy 项目,因此在本章中,我们只是将要使用模型注册功能,让我们继续看看下一节如何操作。

创建一个将模型添加到模型注册表的命令

要将模型添加到模型注册表,我们需要安装 DVCLive Python 包。DVCLive 是一个用于记录机器学习指标和其他元数据的 Python 库:

  1. 让我们安装它:

    python3 -m pip install dvclive
    
  2. 现在,让我们回顾一下我们的 project.yml Weasel 文件。我们在那里定义了 all 工作流程,并包含以下命令:

      all:
        - preprocess
        - train
        - evaluate
        - package
    
  3. 我们将要创建三个新的命令,一个用于使用 DVC 跟踪模型,一个用于将模型推送到远程存储,还有一个用于将模型添加到模型注册表中。

  4. 将模型推送到远程存储,首先我们需要使用 DVC 来跟踪它。我们通过 dvc add 命令来完成这个操作。让我们在 project.yml 文件中创建这个命令:

      - name: track_model
        help: Track model artifact with DVC
        script:
          - "dvc add packages/${vars.language}_${vars.name}-${vars.version}/dist/${vars.language}_${vars.name}-${vars.version}.tar.gz"
        deps:
          - "packages/${vars.language}_${vars.name}-${vars.version}/dist/${vars.language}_${vars.name}-${vars.version}.tar.gz"
    
  5. 现在,让我们使用 Weasel 运行这个命令:

    python3 -m weasel run track_model
    

注意

你应该从 Weasel 创建的根 .gitignore 文件中删除 packages 文件夹,以便将模型包添加到 DVC(DVC 将在 model 文件夹内创建一个新的 .gitignore 文件)。

dvc add 命令为该工件创建一个 .dvc 文件,然后使用 Git 来跟踪这个工件。

  1. 现在,我们可以创建一个将此模型添加到 Google Drive 的命令:

      - name: push_remote
        help: Push model to DVC remote storage
        script:
          - "dvc push packages/${vars.language}_${vars.name}-${vars.version}/dist/${vars.language}_${vars.name}-${vars.version}.tar.gz"
        deps:
          - "packages/${vars.language}_${vars.name}-${vars.version}/dist/${vars.language}_${vars.name}-${vars.version}.tar.gz"
    

    我们也可以使用 Weasel 运行它:

    python3 -m weasel run push_remote
    

这个命令会打开浏览器,以便我们可以使用 Google Drive 进行身份验证。现在,我们终于准备好创建一个将模型添加到 DVC Studio 模型注册表的命令。为此,我们将创建一个新的 Python 脚本。让我们开始吧:

  1. 首先,我们导入库:

    import typer
    from dvclive import Live
    
  2. 现在,我们创建一个带有 DVCLive 日志记录器上下文管理器块的 main() 函数,并使用 log_artifact() 方法来添加模型:

    def main(model: str, description: str, labels: str): 
        with Live() as live: 
            live.log_artifact( 
                str(model), 
                type="model", 
                name="model" 
            )
    
  3. 最后,我们将 typer.run(main) 添加来处理命令行参数:

    if __name__ == "__main__":
      typer.run(main)
    
  4. 我们可以将这个脚本保存为 scripts/add_model_registry.py。现在,我们可以创建一个命令来运行这个脚本:

    - name: add_to_model_registry
        help: Add model to DVC Studio Model Registry
        script:
          - "python scripts/add_to_model_registry.py packages/${vars.language}_${vars.name}-${vars.version}/dist/${vars.language}_${vars.name}-${vars.version}.tar.gz ${vars.name}"
        deps:
          - "scripts/add_to_model_registry.py"
          - "packages/${vars.language}_${vars.name}-${vars.version}/dist/${vars.language}_${vars.name}-${vars.version}.tar.gz"
    
  5. 我们现在应该提交创建的 dvc.yaml 文件:

    git add dvc.yaml
    git commit -m "feat: adding model using dvclive"
    git push
    

    现在,你应该前往 studio.iterative.ai/,使用 GitHub/GitLab/Bitbucket 连接,并导入你的 Git 仓库。完成之后,你可以点击 模型 菜单,在那里可以看到模型。图 9.6 显示了这个界面。

图 9.6 – DVC Studio 模型

图 9.6 – DVC Studio 模型

  1. 下一步是注册模型。点击模型名称,可以看到在 图 9.7 中显示的选项。

图 9.7 – DVC Studio 模型选项

图 9.7 – DVC Studio 模型选项

  1. 现在,点击蓝色的 注册第一个版本 按钮,选择我们模型开发历史中的一个特定提交,并将其附加到一个版本上,以便更容易跟踪它。图 9.8 显示了注册模型弹出窗口。

图 9.8 – DVC Studio 注册模型选项

图 9.8 – DVC Studio 注册模型选项

模型现在已注册,我们可以下载它或将它分配到生命周期阶段。图 9.9显示了这些选项。

图 9.9 – 使用 DVC Studio 访问模型的方法

图 9.9 – 使用 DVC Studio 访问模型的方法

当模型被分配到某个阶段时,它可以自动触发 CI/CD 工作流程中的操作,例如将模型部署到新的环境。你可以在 DVC 文档中了解更多信息:dvc.org/doc/start/model-registry/model-cicd

摘要

在本章中,你学习了如何使用 Weasel 管理 spaCy 项目。首先,你从 spaCy 的仓库克隆了一个项目模板并在你的机器上运行它。然后,你使用相同的项目结构对一个数据集进行模型训练。之后,你看到了 GitOps 如何解决一些数据科学和机器学习挑战,并使用 DVC 注册了我们训练的模型以与队友分享,或者添加部署设置。本章的目标是教你如何在生产环境中管理 NLP 项目。

在下一章中,我们将探讨如何训练用于指代消解的模型。这包括理解什么是指代消解,为什么它在 NLP 中很重要,以及如何使用 spaCy 来实现它。