本系列博客的目地是通过使用两个领先的生产级语言处理库(John Snow Labs的Apache Spark NLP和Explosion AI的spaCy)来处理真实的自然语言处理(NLP)场景,从而对他们做一个比较。这两个库都是开源的,有商业使用许可证(分别是Apache 2.0和MIT)。两者的发展都很活跃,发布也很频繁,且社区不断增长。
我希望能分析和识别这两个库的优点,发现对数据科学家和开发人员而言它们有什么区别,以及在哪些情况下使用其中一种或另一种更方便。本次分析希望进行一次客观的探索,并在几个阶段加入一定量的主观决定(就像每个自然语言理解应用中那样)。
尽管听起来很简单,但比较两个不同的库,并做出可比较的基准测试是非常具有挑战性的。请记住,你的应用程序会与这里所做的有着不同的场景、数据管道、文本特性、硬件设置和一些非功能性要求。
我会假定读者已经熟悉NLP的概念和编程。你们可能没有这两个工具的相关知识,不过我的目标是使代码尽可能自我说明,提高读性,从而不会让读者陷入太多的细节里。这两个库都有公开的文档,并且是完全开源的。所以我建议你先看一下spaCy 101和Spark-NLP快速入门文档。
关于这两个库
Spark-NLP于2017年10月被开源。作为一个Spark库,它是Apache Spark的原生扩展。它以估计器(estimator)和转换器(transformer)的形式引入了一套Spark ML Pipeline 阶段(stage),用来处理分布式数据集。Spark NLP Annotators不仅包括如分词、标准化和词性标注等基本功能,还有诸如高级情感分析、拼写检查、断言状态等其他高级功能。这些都在工作在Spark ML框架内。Spark-NLP用Scala编写,在JVM中运行,并利用了Spark的优化和执行计划。该库目前提供Scala和Python的API。
spaCy是一个流行且易于使用的自然语言处理的Python库。它最近发布了2.0版,其中包含了神经网络、实体识别等非常多的模型。它提供了目前业界领先的准确性和速度,并且拥有一个活跃的开源社区。spaCy的出现至少有三年的时间了,它GitHub上的第一个版本可以追溯到2015年初。
Spark-NLP目前还没有包括一套预训练的模型。而spaCy对七种(欧洲)语言提供了预先训练的模型,因此用户可以快速注入目标句子并在无需训练模型的情况下返回结果,包括分词、词条、词类(POS)、相似性、实体识别等。
这两个库都提供了通过参数在某些级别的自定义,允许在磁盘中保存训练过的管道,并需要开发人员在特定使用案例中开发使用这些库的程序。Spark NLP让把一个NLP管道作为Spark ML机器学习管道(从数据加载、NLP、特征工程、模型训练、超参数调优以及评估)的一部分来嵌入其中更容易。同时由于Spark可以优化整个管道执行过程,从而使Spark NLP的执行速度更快。
基准应用
我在这里编写的程序将预测原始.txt文件中的POS标记。很多数据清理和准备工作都是按顺序进行的。两个应用程序都将使用相同的数据进行训练,并对同一数据进行预测,以实现最大可能的可比性。
我的目的是验证任何统计性程序的两大支柱:
1.准确性,衡量一个程序能够正确预测语言特征的程度
2.性能,这意味着我需要等待多长时间才能达到这样的准确度。以及在程序崩溃或我的孙子长大之前,我可以向程序输入多少输入数据。
为了比较这些指标,我需要确保两个库有最大的可比性。我用了以下配置:
1.一台台式机,操作系统是Linux Mint。带有SSD硬盘和16GB内存,以及4核3.5 GHz的英特尔i5-6600K处理器。
2.训练、测试和带正确结果的数据,这些数据遵循NLTK POS格式(见下文)。
3.安装了spaCy 2.0.5的Jupyter Python 3 Notebook。
4.安装了Spark-NLP 1.3.0和Apache Spark 2.1.1的Apache Zeppelin 0.7.3 Notebook。
数据
用于训练、测试和比对的数据来自 National American Corpus。我用了其中的报纸部分的MASC 3.0.2书面语料库。
我用语料库提供的工具(ANCtool)对数据进行了整理。虽然我可以使用CoNLL数据格式,其中包含很多标记信息,如词条、索引和实体识别。但我更喜欢使用NLTK数据格式,其中包括Penn POS标签。它足以满足我的目的。数据看起来像这样:
Neither|DT Davison|NNP nor|CC most|RBS other|JJ RxP|NNP opponents|NNSdoubt|VBP the|DT efficacy|NN of|IN medications|NNS .|.
如你所见,训练数据中的内容是:
- 检测到的句子的边界(新一行,新的句子)
- 分词的结果(用空格分隔)
- 检测到的POS(用“|”分隔)
在原始文本文件中,所有内容都混在一起、混乱并且没有任何标准边界。
以下是我们要运行的基准测试的关键指标。
基准测试数据集
在本文中,我们将使用两个基准数据集。 第一个非常小,用来进行交互式调试和实验:
- 训练数据:36个.txt文件,总共77 KB
- 测试数据:14个.txt文件,共114 KB
- 需要预测21362个词
第二组数据仍然不是“大数据”,而是一个相对大的数据集,用于评估典型的单机应用场景:
- 训练数据:72个.txt文件,总共150 KB
- 两个测试数据集:9225个.txt文件,75 MB; 1125个文件,15 MB
- 需要预测1千3百万个词
需要注意的是,我们这里并没有评估“大数据”数据集。这是因为虽然spaCy可以利用多核CPU,但它不能像Spark NLP那样原生就可以使用集群。 因此,Spark NLP在使用集群的TB级数据集上的速度要比spaCy快几个数量级。同样,大型机上的数据库的性能也会超过我这里本地安装的MySQL数据库。我的目标是在单机上评估这两个库,并使用这两个库的多核功能。这是开发时常见的情况,也适用于不需要处理大型数据集的应用程序。
开始吧
那么让我们动手吧。 首先,我们必须要导入相关的库并启动。
spaCy
import os
import io
import time
import re
import random
import pandas as pd
import spacy
nlp_model = spacy.load(‘en’, disable=[‘parser’, ‘ner’])
nlp_blank = spacy.blank(‘en’, disable=[‘parser’, ‘ner’])
我禁用了spaCy中的一些管道,以避免不必要的解析器使它过于臃肿。我还使用了一个nlp_model作为参考,这是spaCy提供的一个预先训练好的NLP模型。但我将使用nlp_blank,这将更具代表性,它将是我自行训练的模型。
Spark-NLP
import org.apache.spark.sql.expressions.Window
import org.apache.spark.ml.Pipeline
import com.johnsnowlabs.nlp._
import com.johnsnowlabs.nlp.annotators._
import com.johnsnowlabs.nlp.annotators.pos.perceptron._
import com.johnsnowlabs.nlp.annotators.sbd.pragmatic._
import com.johnsnowlabs.nlp.util.io.ResourceHelper
import com.johnsnowlabs.util.Benchmark
我面临的第一个挑战是我要处理三种完全不同的分词结果,这会导致难以确定一个词是否与分词和POS标记相匹配:
1.spaCy的分词器采用基于规则的方法,并且已经包含了一个词汇表,其中保存了许多常见的缩略语用于分词。
2.SparkNLP的分词器有自己的分词规则。
3.我的训练和测试数据。这些数据按照ANC的标准进行分词。在很多情况下,它分割词的方式与这两个库的分词器完全不同。
所以,为了克服这个问题,我需要决定如何比较一组完全不同的标签的POS标签。对于Spark-NLP,我将保持原样。它的默认规则与ANC开放标准分词格式基本匹配。对于spaCy,我需要放松中缀规则,以便通过不使用“ – ”分割词来增加分词的匹配准确度。
spaCy
class DummyTokenMatch:
def __init__(self, content):
self.start = lambda : 0
self.end = lambda : len(content)
def do_nothing(content):
return [DummyTokenMatch(content)]
model_tokenizer = nlp_model.tokenizer
nlp_blank.tokenizer = spacy.tokenizer.Tokenizer(nlp_blank.vocab, prefix_search=model_tokenizer.prefix_search,
suffix_search=model_tokenizer.suffix_search,
infix_finditer=do_nothing,
token_match=model_tokenizer.token_match)
请注意:我向nlp_blank传递了vocab对象,因此nlp_blank并不是真的空。 这个vocab词汇对象包含有英语语言规则和策略,可帮助我们的空白模型标记POS,并对英语单词进行分词。因此,spaCy开始是有一点小优势的,而Spark-NLP事先并“不懂”英语。
训练管道
现在来到训练这一步。在spaCy里,我需要提供一个指定的训练数据格式,它的格式如下所示:
TRAIN_DATA = [
(“I like green eggs”, {‘tags’: [‘N’, ‘V’, ‘J’, ‘N’]}),
(“Eat blue ham”, {‘tags’: [‘V’, ‘J’, ‘N’]})
]
而在Spark-NLP中,我必须提供一个文件夹,其中包含带“分隔词|标记”格式的.txt数据文件,它看起来就像ANC训练数据。所以,我只需将文件夹路径传递给POS标记器(即PerceptronApproach)。
让我们加载spaCy的训练数据。在下面代码里,我必须添加一些手工生成的例外、规则与一些字符,因为spaCy的训练集需要干净的数据。
spaCy
start = time.time()
train_path = “./target/training/”
train_files = sorted([train_path + f for f in os.listdir(train_path) if os.path.isfile(os.path.join(train_path, f))])
TRAIN_DATA = []
for file in train_files:
fo = io.open(file, mode=’r’, encoding=’utf-8′)
for line in fo.readlines():
line = line.strip()
if line == ”:
continue
line_words = []
line_tags = []
for pair in re.split(“\\s+”, line):
tag = pair.strip().split(“|”)
line_words.append(re.sub(‘(\w+)\.’, ‘\1’, tag[0].replace(‘$’, ”).replace(‘-‘, ”).replace(‘\”, ”)))
line_tags.append(tag[-1])
TRAIN_DATA.append((‘ ‘.join(line_words), {‘tags’: line_tags}))
fo.close()
TRAIN_DATA[240] = (‘The company said the one time provision would substantially eliminate all future losses at the unit .’, {‘tags’: [‘DT’, ‘NN’, ‘VBD’, ‘DT’, ‘JJ’, ‘-‘, ‘NN’, ‘NN’, ‘MD’, ‘RB’, ‘VB’, ‘DT’, ‘JJ’, ‘NNS’, ‘IN’, ‘DT’, ‘NN’, ‘.’]})
n_iter=5
tagger = nlp_blank.create_pipe(‘tagger’)
tagger.add_label(‘-‘)
tagger.add_label(‘(‘)
tagger.add_label(‘)’)
tagger.add_label(‘#’)
tagger.add_label(‘…’)
tagger.add_label(“one-time”)
nlp_blank.add_pipe(tagger)
optimizer = nlp_blank.begin_training()
for i in range(n_iter):
random.shuffle(TRAIN_DATA)
losses = {}
for text, annotations in TRAIN_DATA:
nlp_blank.update(, [annotations], sgd=optimizer, losses=losses)
print(losses)
print (time.time() – start)
运行时间
{‘tagger’: 5.773235303101046}
{‘tagger’: 1.138113870966123}
{‘tagger’: 0.46656132966405683}
{‘tagger’: 0.5513760568314119}
{‘tagger’: 0.2541630900934435}
Time to run: 122.11359786987305 seconds
为了绕过一些坑,我不得不做了一些额外工作。spaCy不让我使用我的分词器的词汇,因为它里面包含了一些丑陋的字符。例如,除非存在于vocab标签中,否则spaCy不会训练带有“large-screen”或“No”标记的句子。我必须将这些字符添加到vocab列表中,以便在训练期间spaCy可以找到它们。
现在,让我们看看在Spark-NLP中如何构建管道的。
Spark-NLP
val documentAssembler = new DocumentAssembler()
.setInputCol(“text”)
.setOutputCol(“document”)
val tokenizer = new Tokenizer()
.setInputCols(“document”)
.setOutputCol(“token”)
.setPrefixPattern(“\\A([^\\s\\p{L}\\d\\$\\.#]*)”)
.addInfixPattern(“(\\$?\\d+(?:[^\\s\\d]{1}\\d+)*)”)
val posTagger = new PerceptronApproach()
.setInputCols(“document”, “token”)
.setOutputCol(“pos”)
.setCorpusPath(“/home/saif/nlp/comparison/target/training”)
.setNIterations(5)
val finisher = new Finisher()
.setInputCols(“token”, “pos”)
.setOutputAsArray(true)
val pipeline = new Pipeline()
.setStages(Array(
documentAssembler,
tokenizer,
posTagger,
finisher
))
val model = Benchmark.time(“Time to train model”) {
pipeline.fit(data)
}
正如你所看到的,构建一个管道是一个非常线性的过程:设置文档组装器,这使得目标文本列成为后续注释器(即分词器)的目标;接着PerceptronApproach就是POS模型,它将同时接收文档文本和符号化表单作为输入。
我必须更新前缀模式,并添加一个新的中缀模式,以便使用和ANC相同的方式匹配日期和数字(这可能会在Spark NLP的下一版本中成为默认模式)。正如你所看到的,管道的每个组件都在用户的控制之下; 没有隐含的vocab或英语知识,这和spaCy不同。
来自PerceptronApproach的corpusPath被传递到包含管道分隔文本文件的文件夹。finisher注释器包装POS和分词的结果,以便下一步使用它。正如SetOutputAsArray()的名字所表示的,它会返回一个数组而不是一个拼接起来的字符串,不过这在处理时会有一定的计算代价。
传递给fit()的数据并不重要,因为唯一被训练的NLP标注器是PerceptronApproach。而且这个标注器是用外部POS Corpora进行训练的。
运行时间
Time to train model: 3.167619593sec
作为一个附注,可以在管道中注入SentenceDetector或SpellChecker。这样在某些情况下,可以通过让模型知道句子结束的位置来帮助提升POS的准确性。
接下来做什么?
到目前为止,我们已经初始化了库,加载了数据,并且用两个库都训练了一个分词器模型。需要注意的是,spaCy带有一个预先训练好的分词器,因此如果你的文本数据来自于spaCy训练过的语言(例如英语)和领域(例如新闻报道),这一步可能不是必需的。但为了让生成的符号与我们的ANC语料库更匹配,对分词中缀的修改是非常重要的。在迭代5次的情况下,Spark-NLP的训练速度比spaCy快了38倍多。
在此系列的下一篇中我们将通过使用刚刚训练出的模型来运行NLP管道,介绍代码、准确性和性能。