Java-自然语言处理-二-

64 阅读16分钟

Java 自然语言处理(二)

三、搜索语句

将文本分割成句子也叫句子边界消歧()。这个过程对于许多下游的需要在句子中分析的 NLP 任务是有用的;例如,词性和短语分析通常在一个句子中进行。

**在这一章中,我们将解释为什么 SBD 是困难的。然后,我们将研究一些在某些情况下可能有效的核心 Java 方法,并继续讨论各种 NLP APIs 对模型的使用。我们还将研究句子检测模型的训练和验证方法。我们可以添加额外的规则来进一步优化这个过程,但是这只能在一定程度上起作用。之后,模型必须被训练来处理普通和特殊的情况。本章的后半部分着重于这些模型及其使用。

我们将在本章中讨论以下主题:

  • SBD 进程
  • 是什么让 SBD 变得困难?
  • 使用 NLP APIs
  • 训练句子检测器模型

SBD 进程

SBD 过程依赖于语言,并且通常不简单。检测句子的常见方法包括使用一组规则或训练一个模型来检测它们。下面是一组检测句子的简单规则。如果下列条件为真,则检测到句子结束:

  • 文本以句点、问号或感叹号结束
  • 句点前面没有缩写,后面也没有数字

虽然这对于大多数句子来说很有效,但并不是对所有的句子都有效。例如,确定什么是缩写并不总是容易的,像省略号这样的序列可能会与句号混淆。

大多数搜索引擎并不关心 SBD。他们只对查询的标记及其位置感兴趣。执行数据提取的词性标注和其他 NLP 任务将经常处理单个句子。句子边界的检测将有助于分离看起来可能跨越句子的短语。例如,考虑下面的句子:

“建设过程结束了。盖房子的那座小山很矮。”

如果我们正在搜索短语 over the hill ,我们会不经意地在这里找到它。

本章中的许多例子将使用下面的文字来演示 SBD。这篇课文由三个简单的句子组成,后面跟着一个更复杂的句子:

private static String paragraph = "When determining the end of sentences " 
    + "we need to consider several factors. Sentences may end with " 
    + "exclamation marks! Or possibly questions marks? Within " 
    + "sentences we may find numbers like 3.14159, abbreviations " 
    + "such as found in Mr. Smith, and possibly ellipses either " 
    + "within a sentence ..., or at the end of a sentence..."; 

是什么让 SBD 变得困难?

将文本分解成句子很困难,原因有很多:

  • 标点符号经常含糊不清
  • 缩写通常包含句点
  • 通过使用引号,句子可以相互嵌入
  • 对于更专业的文本,比如 tweets 和聊天会话,我们可能需要考虑使用新行或完成从句

标点歧义最好用句号来说明。它经常被用来区分一个句子的结尾。然而,它也可以用在许多其他上下文中,包括缩写、数字、电子邮件地址和省略号。其他标点符号,如问号和感叹号,也用在嵌入的引号和特殊文本中,如可能在文档中的代码。

句点用于多种情况:

  • 终止一项判决
  • 以缩写结尾
  • 结束一个缩写并结束一个句子
  • 对于省略号
  • 对于句末的省略号
  • 嵌入在引号或括号中

我们遇到的大多数句子都以句号结尾。这使得它们易于识别。然而,当它们以缩写结尾时,识别它们就有点困难了。以下句子包含带句点的缩写:

"史密斯夫妇去参加舞会了."

在下面的两个句子中,我们有一个出现在句末的缩写:

"他是中央情报局的特工。"

"他是中央情报局的特工。"

在最后一句中,缩写的每个字母后面都有一个句点。虽然不常见,但这可能会发生,我们不能简单地忽视它。

另一个让 SBD 感到困难的问题是试图确定一个单词是否是一个缩写。我们不能简单地把所有的大写序列都当成缩写。也许用户不小心输入了一个全部大写的单词,或者文本被预处理以将所有字符转换成小写。此外,一些缩写由一系列大写和小写字母组成。为了处理缩写,有时会使用有效缩写的列表。然而,缩写通常是特定领域的。

省略号会使问题更加复杂。它们可能是单个字符(扩展 ASCII 0 x 85 或 Unicode (U+2026))或三个句点的序列。此外,还有 Unicode 水平省略号(U+2026)、垂直省略号(U+22EE)以及垂直和水平省略号的表示形式(U+FE19)。除了这些,还有 HTML 编码。对于 Java,使用\uFE19。编码上的这些变化说明了在分析文本之前对其进行良好预处理的必要性。

下面两个句子说明了省略号的可能用法:

“然后就有了...一个。”

"这份名单还在继续,而且……"

第二句以省略号结尾。在某些情况下,正如《司法协助手册》(www.mlahandbook.org/fragment/public_index)所建议的,我们可以使用括号来区分添加的省略号和原文本中的省略号,如下所示:

“人民[...使用各种交通工具...]" ( 少年 73 )。

我们还会发现嵌入在另一个句子中的句子,比如:

那人说:“那不对。”

感叹号和问号代表其他问题,即使这些字符的出现比句点更有限。感叹号可以出现在句尾以外的地方。在某些词的情况下,比如 Yahoo!感叹号是单词的一部分。此外,多个感叹号用于强调,如“最美好的祝愿!!"这可以导致识别实际上不存在的多个句子。

理解 LingPipe 的 HeuristicSentenceModel 类的 SBD 规则

还有其他规则可以用来执行 SBD。LingPipe 的HeuristicSentenceModel类使用一系列令牌规则来执行 SBD。我们将在这里展示它们,因为它们提供了对哪些规则有用的洞察。

这个类使用三组标记和两个标志来帮助这个过程:

  • 可能的停顿:这是一组标记,可以是一个句子的最后一个标记
  • 不可能的倒数第二个单词:这些单词不能是句子中倒数第二个单词
  • 不可能开始:这是一组不能用来开始一个句子的标记
  • 平衡括号:该标志表示在一个句子中所有匹配的括号都匹配之前,该句子不应被终止
  • Force final boundary :这指定输入流中的最后一个标记应该被视为语句结束符,即使它不是一个可能的终止符

平衡括号包括()和[]。但是,如果文本格式不正确,此规则将失败。下表列出了默认令牌集:

| 可能的停靠点 | 不可能的倒数第二名 | 不可能的开始 | | 。 | 任何一个字母 | 闭括号 | | .. | 个人和专业头衔、军衔等等 | , | | ! | 逗号、冒号和引号 | ; | | ? | 常见缩写 | : | | " | 方向 | - | | '' | 公司标志 | - | | ). | 时间、月份等等 | - | | | 美国政党 | % | | | 美国各州(不是我或我所在的州) | " | | | 运货条款 | | | | 地址缩写 | |

尽管 LingPipe 的HeuristicSentenceModel类使用了这些规则,但是没有理由说它们不能在 SBD 工具的其他实现中使用。

SBD 的启发式方法可能不总是像其他技术一样准确。然而,它们可能在特定的领域中工作,并且通常具有更快和使用更少内存的优势。

简单 Java SBDs

有时,文本可能足够简单,Java 核心支持就足够了。有两种方法可以执行 SBD:使用正则表达式和使用BreakIterator类。我们将在这里研究这两种方法。

使用正则表达式

正则表达式可能很难理解。虽然简单的表达式通常不是问题,但是随着它们变得越来越复杂,它们的可读性也会变差。当试图将正则表达式用于 SBD 时,这是正则表达式的局限性之一。

我们将给出两种不同的正则表达式。第一个表达式很简单,但是做得不太好。它展示了一个对于某些问题领域来说可能过于简单的解决方案。第二个更复杂,做得更好。

在本例中,我们创建了一个匹配句点、问号和感叹号的正则表达式类。String class' split方法用于将文本拆分成句子:

String simple = "[.?!]"; 
String[] splitString = (paragraph.split(simple)); 
for (String string : splitString) { 
    System.out.println(string); 
}

输出如下所示:

    When determining the end of sentences we need to consider several factors
     Sentences may end with exclamation marks
     Or possibly questions marks
     Within sentences we may find numbers like 3
    14159, abbreviations such as found in Mr
     Smith, and possibly ellipses either within a sentence ..., or at the end of a sentence...

正如所料,该方法将段落分割成字符,而不管它们是数字还是缩写的一部分。

第二种方法会产生更好的结果。这个例子改编自stack overflow . com/questions/5553410/regular-expression-match-a-sentence上的一个例子。使用了编译以下正则表达式的Pattern类:

    [^.!?\s][^.!?]*(?:.!?[^.!?]*)*[.!?]?['"]?(?=\s|$)

以下代码序列中的注释解释了每个部分的含义:

Pattern sentencePattern = Pattern.compile( 
    "# Match a sentence ending in punctuation or EOS.\n" 
    + "[^.!?\\s]    # First char is non-punct, non-ws\n" 
    + "[^.!?]*      # Greedily consume up to punctuation.\n" 
    + "(?:          # Group for unrolling the loop.\n" 
    + "  [.!?]      # (special) inner punctuation ok if\n" 
    + "  (?!['\"]?\\s|$)  # not followed by ws or EOS.\n" 
    + "  [^.!?]*    # Greedily consume up to punctuation.\n" 
    + ")*           # Zero or more (special normal*)\n" 
    + "[.!?]?       # Optional ending punctuation.\n" 
    + "['\"]?       # Optional closing quote.\n" 
    + "(?=\\s|$)", 
    Pattern.MULTILINE | Pattern.COMMENTS); 

使用在regexper.com/找到的显示工具可以生成该表达式的另一种表示。如下图所示,它以图形方式描述了表达式,并阐明了其工作原理:

对示例段落执行matcher方法,然后显示结果:

Matcher matcher = sentencePattern.matcher(paragraph); 
while (matcher.find()) { 
    System.out.println(matcher.group()); 
} 

输出如下。保留了句子终止符,但缩写仍然存在问题:

    When determining the end of sentences we need to consider several factors.
    Sentences may end with exclamation marks!
    Or possibly questions marks?
    Within sentences we may find numbers like 3.14159, abbreviations such as found in Mr.
    Smith, and possibly ellipses either within a sentence ..., or at the end of a sentence...

使用 BreakIterator 类

BreakIterator类可以用来检测各种文本边界,比如字符、单词、句子和行之间的边界。不同的方法用于创建不同的BreakIterator类实例,如下所示:

  • 对于字符,使用getCharacterInstance方法
  • 对于单词,使用getWordInstance方法
  • 对于句子,使用getSentenceInstance方法
  • 对于线,使用getLineInstance方法

检测字符之间的分隔符有时很重要,例如,当我们需要处理由多个 Unicode 字符组成的字符时,比如ü。该字符有时由\u0075 (u)和\u00a8 ( ) Unicode 字符组合而成。该类将识别这些类型的字符。这种能力在 docs.oracle.com/javase/tuto…有更详细的说明。

BreakIterator类可以用来检测一个句子的结尾。它使用引用当前边界的光标。它支持一个next和一个previous方法,分别在文本中向前和向后移动光标。BreakIterator有一个受保护的默认构造函数。要获得一个BreakIterator类的实例来检测句子的结尾,使用静态的getSentenceInstance方法,如下所示:

BreakIterator sentenceIterator = 
 BreakIterator.getSentenceInstance(); 

还有一个方法的重载版本。它将一个Locale实例作为参数:

Locale currentLocale = new Locale("en", "US"); 
BreakIterator sentenceIterator =  
    BreakIterator.getSentenceInstance(currentLocale); 

一旦创建了一个实例,setText方法将把文本关联到 ,用迭代器进行处理:

sentenceIterator.setText(paragraph); 

BreakIterator使用一系列方法和字段识别文本中的边界。所有这些函数都返回整数值,下表对它们进行了详细说明:

| 方法 | 用途 | | first | 返回文本的第一个边界 | | next | 返回当前边界之后的边界 | | previous | 返回当前边界之前的边界 | | DONE | 最后一个整数,赋值为-1(表示没有边界可寻) |

为了以连续的方式使用迭代器,使用first方法识别第一个边界,然后重复调用next方法来寻找后续的边界。当DONE返回时,过程终止。下面的代码序列说明了这种技术,它使用了之前声明的sentenceIterator实例:

int boundary = sentenceIterator.first(); 
while (boundary != BreakIterator.DONE) { 
    int begin = boundary; 
    System.out.print(boundary + "-"); 
    boundary = sentenceIterator.next(); 
    int end = boundary; 
    if (end == BreakIterator.DONE) { 
        break; 
    } 
    System.out.println(boundary + " [" 
        + paragraph.substring(begin, end) + "]"); 
} 

在执行时,我们得到以下输出:

    0-75 [When determining the end of sentences we need to consider several factors. ]
    75-117 [Sentences may end with exclamation marks! ]
    117-146 [Or possibly questions marks? ]
    146-233 [Within sentences we may find numbers like 3.14159 , abbreviations such as found in Mr. ]
    233-319 [Smith, and possibly ellipses either within a sentence ... , or at the end of a sentence...]
    319-

该输出适用于简单的句子,但不适用于更复杂的句子。

正则表达式和BreakIterator类的使用都有局限性。它们对于由相对简单的句子组成的文本很有用。然而,当文本变得更加复杂时,最好使用 NLP APIs,这将在下一节中讨论。

使用 NLP APIs

有许多支持 SBD 的 NLP API 类。一些是基于规则的,而另一些则使用使用常见和不常见文本训练的模型。我们将使用 OpenNLP、Stanford 和 LingPipe APIs 说明句子检测类的用法。

模型也可以被训练。关于这种方法的讨论在训练句子检测器模型一节中进行了说明。在处理专业文本(如医学或法律文本)时,需要专业模型。

使用 OpenNLP

OpenNLP 使用模型来执行 SBD。基于一个模型文件,创建了一个SentenceDetectorME类的实例。通过sentDetect方法返回句子,通过sentPosDetect方法返回位置信息。

使用 SentenceDetectorME 类

使用SentenceModel类从文件中加载模型。然后使用该模型创建一个SentenceDetectorME类的实例,并调用sentDetect方法来执行 SBD。该方法返回一个字符串数组,每个元素包含一个句子。

下面的示例演示了这一过程。try-with-resources 块用于打开包含模型的en-sent.bin文件。然后,处理paragraph字符串。接下来,捕捉各种 IO 类型异常(如有必要)。最后,使用 for-each 语句来显示句子:

try (InputStream is = new FileInputStream( 
        new File(getModelDir(), "en-sent.bin"))) { 
    SentenceModel model = new SentenceModel(is); 
    SentenceDetectorME detector = new SentenceDetectorME(model); 
    String sentences[] = detector.sentDetect(paragraph); 
    for (String sentence : sentences) { 
        System.out.println(sentence); 
    } 
} catch (FileNotFoundException ex) { 
    // Handle exception 
} catch (IOException ex) { 
    // Handle exception 
}

在执行时,我们得到以下输出:

    When determining the end of sentences we need to consider several factors.
    Sentences may end with exclamation marks!
    Or possibly questions marks?
    Within sentences we may find numbers like 3.14159, abbreviations such as found in Mr. Smith, and possibly ellipses either within a sentence ..., or at the end of a sentence...

这一段的输出效果很好。它既能捕捉简单的句子,也能捕捉更复杂的句子。当然,经过处理的文本并不总是完美的。下面的段落在某些地方有多余的空格,但在需要的地方缺少空格。此问题可能出现在聊天会话分析中:

paragraph = " This sentence starts with spaces and ends with "  
    + "spaces . This sentence has no spaces between the next " 
    + "one.This is the next one."; 

当我们在前面的例子中使用这一段时,我们得到下面的输出:

    This sentence starts with spaces and ends with spaces  .
    This sentence has no spaces between the next one.This is the next one.

第一句的前导空格被删除,但结尾空格没有删除。第三句没有检测出来,和第二句合并了。

getSentenceProbabilities方法返回一个 doubles 数组,表示从最后一次使用sentDetect方法中检测到的句子的置信度。在显示句子的 for-each 语句后添加以下代码:

double probablities[] = detector.getSentenceProbabilities(); 
for (double probablity : probablities) { 
    System.out.println(probablity); 
} 

通过执行原始段落,我们得到以下输出:

    0.9841708738988814
    0.908052385070974
    0.9130082376342675
    1.0

显示的数字是表示置信度的概率。

使用 sentPosDetect 方法

SentenceDetectorME类拥有一个为每个句子返回Span对象的sentPosDetect方法。使用与上一节相同的代码,除了两处更改:用sentPosDetect方法替换sentDetect方法,用这里使用的方法替换 for-each 语句:

Span spans[] = detector.sentPosDetect(paragraph); 
for (Span span : spans) { 
    System.out.println(span); 
} 

接下来的输出使用原始段落。Span对象包含默认执行toString方法返回的位置信息:

    [0..74)
    [75..116)
    [117..145)
    [146..317)  

Span类拥有许多方法。下面的代码序列演示了如何使用getStartgetEnd方法来清楚地显示这些跨度所代表的文本:

for (Span span : spans) { 
    System.out.println(span + "[" + paragraph.substring( 
        span.getStart(), span.getEnd()) +"]"); 
} 

输出显示识别的句子:

     [0..74)[When determining the end of sentences we need to consider several factors.]
    [75..116)[Sentences may end with exclamation marks!]
    [117..145)[Or possibly questions marks?]
    [146..317)[Within sentences we may find numbers like 3.14159, abbreviations such as found in Mr. Smith, and possibly ellipses either within a sentence ..., or at the end of a sentence...]

还有许多其他有价值的方法。下表列出了这些功能:

| 方法 | 意为 | | contains | 确定另一个Span对象或索引是否包含在目标中的重载方法 | | crosses | 确定两个跨度是否重叠 | | length | 跨度的长度 | | startsWith | 确定跨度是否从目标跨度开始 |

使用斯坦福 API

斯坦福 NLP 库支持几种用于执行句子检测的技术。在本节中,我们将使用以下类来演示这一过程:

  • PTBTokenizer
  • DocumentPreprocessor
  • StanfordCoreNLP

尽管它们都执行 SBD,但每个都使用不同的方法来执行流程。

使用 PTBTokenizer 类

PTBTokenizer类使用规则来执行 SBD,并有多种标记化选项。这个类的构造函数拥有三个参数:

  • 封装要处理的文本的Reader
  • 实现LexedTokenFactory接口的对象
  • 保存标记化选项的字符串

这些选项允许我们指定文本、要使用的标记器以及我们可能需要用于特定文本流的任何选项。

在下面的代码序列中,创建了一个StringReader类的实例来封装文本。在本例中,CoreLabelTokenFactory类与选项一起使用,剩下的选项为null:

PTBTokenizer ptb = new PTBTokenizer(new StringReader(paragraph), 
     new CoreLabelTokenFactory(), null); 

我们将使用WordToSentenceProcessor类创建一个List类的List实例来保存句子及其标记。它的process方法使用由PTBTokenizer实例产生的令牌来创建List类的列表,如下所示:

WordToSentenceProcessor wtsp = new WordToSentenceProcessor(); 
List<List<CoreLabel>> sents = wtsp.process(ptb.tokenize());

List类的该List实例可以通过多种方式显示。在下面的序列中,List类的toString方法显示括在括号中的列表,其元素用逗号分隔:

for (List<CoreLabel> sent : sents) { 
    System.out.println(sent); 
} 

该序列的输出产生以下内容:

    [When, determining, the, end, of, sentences, we, need, to, consider, several, factors, .]
    [Sentences, may, end, with, exclamation, marks, !]
    [Or, possibly, questions, marks, ?]
    [Within, sentences, we, may, find, numbers, like, 3.14159, ,, abbreviations, such, as, found, in, Mr., Smith, ,, and, possibly, ellipses, either, within, a, sentence, ..., ,, or, at, the, end, of, a, sentence, ...]  

此处显示的另一种方法是在单独的行上显示每个句子:

for (List<CoreLabel> sent : sents) { 
    for (CoreLabel element : sent) { 
        System.out.print(element + " "); 
     } 
    System.out.println(); 
} 

输出如下所示:

    When determining the end of sentences we need to consider several factors . 
    Sentences may end with exclamation marks ! 
    Or possibly questions marks ? 
    Within sentences we may find numbers like 3.14159 , abbreviations such as found in Mr. Smith , and possibly ellipses either within a sentence ... , or at the end of a sentence ... 

如果我们只对单词和句子的位置感兴趣,我们可以使用endPosition方法,如下所示:

for (List<CoreLabel> sent : sents) { 
    for (CoreLabel element : sent) { 
        System.out.print(element.endPosition() + " "); 
     } 
    System.out.println(); 
} 

当执行这个命令时,我们得到以下输出。每行的最后一个数字是句子边界的索引:

    4 16 20 24 27 37 40 45 48 57 65 73 74 
    84 88 92 97 109 115 116 
    119 128 138 144 145 
    152 162 165 169 174 182 187 195 196 210 215 218 224 227 231 237 238 242 251 260 267 274 276 285 287 288 291 294 298 302 305 307 316 317

每个句子的第一个元素及其索引按以下顺序显示:

for (List<CoreLabel> sent : sents) { 
    System.out.println(sent.get(0) + " "  
        + sent.get(0).beginPosition()); 
} 

输出如下所示:

    When 0
    Sentences 75
    Or 117
    Within 146

如果我们对一个句子的最后成分感兴趣,我们可以用下面的顺序。列表元素的数量用于显示终止字符及其结束位置:

for (List<CoreLabel> sent : sents) { 
    int size = sent.size(); 
    System.out.println(sent.get(size-1) + " "  
        + sent.get(size-1).endPosition()); 
} 

这将产生以下输出:

    . 74
    ! 116
    ? 145
    ... 317  

当调用PTBTokenizer类的构造函数时,有许多选项可用。这些选项包含在构造函数的第三个参数中。选项字符串由逗号分隔的选项组成,如下所示:

"americanize=true,normalizeFractions=true,asciiQuotes=true".

下表列出了这些选项中的几个:

| 选项 | 意为 | | invertible | 用于指示必须保留标记和空白,以便可以重建原始字符串 | | tokenizeNLs | 指示行尾必须被视为标记 | | americanize | 如果是真的,这将把英式拼写改写成美式拼写 | | normalizeAmpersandEntity | 将 XML & amp 字符转换为& amp 符号 | | normalizeFractions | 将常见的分数字符(如)转换为长格式(1/2) | | asciiQuotes | 会将引号字符转换为更简单的“和”字符 | | unicodeQuotes | 会将引号字符转换为范围从 U+2018 到 U+201D 的字符 |

以下序列说明了此选项字符串的用法:

paragraph = "The colour of money is green. Common fraction " 
    + "characters such as ½  are converted to the long form 1/2\. " 
    + "Quotes such as "cat" are converted to their simpler form."; 
ptb = new PTBTokenizer( 
    new StringReader(paragraph), new CoreLabelTokenFactory(), 
    "americanize=true,normalizeFractions=true,asciiQuotes=true"); 
wtsp = new WordToSentenceProcessor(); 
sents = wtsp.process(ptb.tokenize()); 
for (List<CoreLabel> sent : sents) { 
    for (CoreLabel element : sent) { 
        System.out.print(element + " "); 
    } 
    System.out.println(); 
} 

输出如下所示:

    The color of money is green . 
    Common fraction characters such as 1/2 are converted to the long form 1/2 . 
    Quotes such as " cat " are converted to their simpler form . 

“colour”一词的英国拼法被转换成了美国的对应拼法。分数½展开为三个字符:1/2。在最后一句中,智能引号被转换成了更简单的形式。

使用 document 预处理程序类

DocumentPreprocessor类的一个实例被创建时,它使用它的Reader参数产生一个句子列表。它还实现了Iterable接口,这使得遍历列表变得很容易。

在下面的示例中,该段落用于创建一个StringReader对象,该对象用于实例化DocumentPreprocessor实例:

Reader reader = new StringReader(paragraph); 
DocumentPreprocessor dp = new DocumentPreprocessor(reader); 
for (List sentence : dp) { 
    System.out.println(sentence); 
} 

在执行时,我们得到以下输出:

    [When, determining, the, end, of, sentences, we, need, to, consider, several, factors, .]
    [Sentences, may, end, with, exclamation, marks, !]
    [Or, possibly, questions, marks, ?]
    [Within, sentences, we, may, find, numbers, like, 3.14159, ,, abbreviations, such, as, found, in, Mr., Smith, ,, and, possibly, ellipses, either, within, a, sentence, ..., ,, or, at, the, end, of, a, sentence, ...]  

默认情况下,PTBTokenizer用于标记输入。setTokenizerFactory方法可以用来指定一个不同的记号赋予器。还有其他几种有用的方法,如下表所示:

| 方法 | 目的 | | setElementDelimiter | 它的参数指定了一个 XML 元素。只会处理这些元素中的文本。 | | setSentenceDelimiter | 处理器将假设字符串参数是一个句子分隔符。 | | setSentenceFinalPuncWords | 它的字符串数组参数指定了句子的结束分隔符。 | | setKeepEmptySentences | 当与空白模型一起使用时,如果它的参数是true,空句将被保留。 |

该类可以处理纯文本或 XML 文档。

为了演示如何处理 XML 文件,我们将创建一个名为XMLText.xml的简单 XML 文件,其中包含以下数据:

<?xml version="1.0" encoding="UTF-8"?> 
<?xml-stylesheet type="text/xsl"?> 
<document> 
    <sentences> 
        <sentence id="1"> 
            <word>When</word> 
            <word>the</word> 
            <word>day</word> 
            <word>is</word> 
            <word>done</word> 
            <word>we</word> 
            <word>can</word> 
            <word>sleep</word> 
            <word>.</word> 
        </sentence> 
        <sentence id="2"> 
            <word>When</word> 
            <word>the</word> 
            <word>morning</word> 
            <word>comes</word> 
            <word>we</word> 
            <word>can</word> 
            <word>wake</word> 
            <word>.</word> 
        </sentence> 
        <sentence id="3"> 
            <word>After</word> 
            <word>that</word> 
            <word>who</word> 
            <word>knows</word> 
            <word>.</word> 
        </sentence> 
    </sentences> 
</document> 

我们将重用前面例子中的代码。但是,我们将打开XMLText.xml文件,并使用DocumentPreprocessor.DocType.XML作为DocumentPreprocessor类的构造函数的第二个参数,如下面的代码所示。这将指定处理器应该将文本视为 XML 文本。此外,我们将指定只处理那些在<sentence>标记内的 XML 元素:

try { 
    Reader reader = new FileReader("XMLText.xml"); 
    DocumentPreprocessor dp = new DocumentPreprocessor( 
        reader, DocumentPreprocessor.DocType.XML); 
    dp.setElementDelimiter("sentence"); 
    for (List sentence : dp) { 
        System.out.println(sentence); 
    } 
} catch (FileNotFoundException ex) { 
    // Handle exception 
} 

该示例的输出如下:

    [When, the, day, is, done, we, can, sleep, .] 
    [When, the, morning, comes, we, can, wake, .]
    [After, that, who, knows, .]  

使用ListIterator可以得到更清晰的输出,如下所示:

for (List sentence : dp) { 
    ListIterator list = sentence.listIterator(); 
     while (list.hasNext()) { 
        System.out.print(list.next() + " "); 
    } 
    System.out.println(); 
} 

它的输出如下:

    When the day is done we can sleep . 
    When the morning comes we can wake . 
    After that who knows . 

如果我们没有指定元素分隔符,每个单词将显示如下:

    [When]
    [the]
    [day]
    [is]
    [done]
    ...
    [who]
    [knows]
    [.]

使用 StanfordCoreNLP 类

StanfordCoreNLP类支持使用ssplit注释器进行句子检测。在下面的例子中,使用了tokenizessplit标注器。创建一个管道对象,并对管道应用annotate方法,使用段落作为其参数:

Properties properties = new Properties(); 
properties.put("annotators", "tokenize, ssplit"); 
StanfordCoreNLP pipeline = new StanfordCoreNLP(properties); 
Annotation annotation = new Annotation(paragraph); 
pipeline.annotate(annotation); 

输出包含大量信息。这里只显示了第一行的输出:

    Sentence #1 (13 tokens):
    When determining the end of sentences we need to consider several factors.
    [Text=When CharacterOffsetBegin=0 CharacterOffsetEnd=4] [Text=determining CharacterOffsetBegin=5 CharacterOffsetEnd=16] [Text=the CharacterOffsetBegin=17 CharacterOffsetEnd=20] [Text=end CharacterOffsetBegin=21 CharacterOffsetEnd=24] [Text=of CharacterOffsetBegin=25 CharacterOffsetEnd=27] [Text=sentences CharacterOffsetBegin=28 CharacterOffsetEnd=37] [Text=we CharacterOffsetBegin=38 CharacterOffsetEnd=40] [Text=need CharacterOffsetBegin=41 CharacterOffsetEnd=45] [Text=to CharacterOffsetBegin=46 CharacterOffsetEnd=48] [Text=consider CharacterOffsetBegin=49 CharacterOffsetEnd=57] [Text=several CharacterOffsetBegin=58 CharacterOffsetEnd=65] [Text=factors CharacterOffsetBegin=66 CharacterOffsetEnd=73] [Text=. CharacterOffsetBegin=73 CharacterOffsetEnd=74] 

或者,我们可以使用xmlPrint方法。这将产生 XML 格式的输出,这通常更容易提取感兴趣的信息。 这里展示了这个方法,它需要处理IOException:

try { 
    pipeline.xmlPrint(annotation, System.out); 
} catch (IOException ex) { 
    // Handle exception 
}

部分输出如下所示:

<?xml version="1.0" encoding="UTF-8"?> 
<?xml-stylesheet href="CoreNLP-to-HTML.xsl" type="text/xsl"?> 
<root> 
  <document> 
    <sentences> 
      <sentence id="1"> 
        <tokens> 
          <token id="1"> 
            <word>When</word> 
            <CharacterOffsetBegin>0</CharacterOffsetBegin> 
            <CharacterOffsetEnd>4</CharacterOffsetEnd> 
          </token> 
... 
         <token id="34"> 
            <word>...</word> 
            <CharacterOffsetBegin>316</CharacterOffsetBegin> 
            <CharacterOffsetEnd>317</CharacterOffsetEnd> 
          </token> 
        </tokens> 
      </sentence> 
    </sentences> 
  </document> 
</root> 

使用 LingPipe

LingPipe 使用类的层次结构来支持 SBD,如下图所示:

这个层次结构的基础是 AbstractSentenceModel 类,它的主要方法是一个重载的boundaryIndices方法。这个方法返回一个边界索引的整数数组,其中数组的每个元素代表一个句子边界。

从这个类派生的是 HeuristicSentenceModel 类。这个类使用一系列可能的停止、不可能的倒数第二和不可能的开始标记集。这些在前面的理解 LingPipe 的 HeuristicSentenceModel 类部分已经讨论过了。

indeuropeansentcemodelMedlineSentenceModel 类是从 HeuristicSentenceModel 类派生而来的。他们分别接受过英语培训和医学专业培训。我们将在下面的小节中演示这两个类。

使用 IndoEuropeanSentenceModel 类

IndoEuropeanSentenceModel模型用于英文文本。它的双参数构造函数将指定:

  • 最后一个令牌是否必须是一个停止符
  • 括号是否应该平衡

默认构造函数不强制最后一个标记是一个停止符,也不期望括号应该是平衡的。句子模型需要和分词器一起使用。为此,我们将使用IndoEuropeanTokenizerFactory类的默认构造函数,如下所示:

TokenizerFactory TOKENIZER_FACTORY= 
 IndoEuropeanTokenizerFactory.INSTANCE; 
com.aliasi.sentences.SentenceModel sentenceModel = new IndoEuropeanSentenceModel(); 

创建一个标记化器,并调用它的tokenize方法来填充两个列表:

List<String> tokenList = new ArrayList<>(); 
List<String> whiteList = new ArrayList<>(); 
Tokenizer tokenizer= TOKENIZER_FACTORY.tokenizer( 
    paragraph.toCharArray(),0, paragraph.length()); 
tokenizer.tokenize(tokenList, whiteList);

boundaryIndices方法返回一个整数边界索引数组。该方法需要两个包含标记和空格的String数组参数。tokenize方法为这些元素使用了两个列表。这意味着我们需要将列表转换成等价的数组,如下所示:

String[] tokens = new String[tokenList.size()]; 
String[] whites = new String[whiteList.size()]; 
tokenList.toArray(tokens); 
whiteList.toArray(whites); 

然后我们可以使用boundaryIndices方法并显示索引:

int[] sentenceBoundaries= 
 sentenceModel.boundaryIndices(tokens, whites); 
for(int boundary : sentenceBoundaries) { 
    System.out.println(boundary); 
} 

输出如下所示:

    12
    19
    24  

为了显示实际的句子,我们将使用下面的顺序。空白索引与标记相差一个:

int start = 0; 
for(int boundary : sentenceBoundaries) { 
    while(start<=boundary) { 
        System.out.print(tokenList.get(start) 
     + whiteList.get(start+1)); 
        start++; 
    } 
    System.out.println(); 
} 

以下输出是结果:

    When determining the end of sentences we need to consider several factors. 
    Sentences may end with exclamation marks! 
    Or possibly questions marks?

可惜,它漏掉了最后一句。这是因为最后一句以省略号结尾。如果我们在句尾添加一个句点,我们会得到以下输出:

    When determining the end of sentences we need to consider several factors. 
    Sentences may end with exclamation marks! 
    Or possibly questions marks? 
    Within sentences we may find numbers like 3.14159, abbreviations such as found in Mr. Smith, and possibly ellipses either within a sentence ..., or at the end of a sentence....

使用 SentenceChunker 类

另一种方法是使用SentenceChunker类来执行 SBD。这个类的构造函数需要一个TokenizerFactory对象和一个SentenceModel对象,如下所示:

TokenizerFactory tokenizerfactory = 
 IndoEuropeanTokenizerFactory.INSTANCE; 
SentenceModel sentenceModel = new IndoEuropeanSentenceModel(); 

使用tokenizerfactory和 句子实例创建SentenceChunker实例:

SentenceChunker sentenceChunker =  
    new SentenceChunker(tokenizerfactory, sentenceModel); 

SentenceChunker类实现了Chunker接口,该接口使用了一个chunk方法。这个方法返回一个实现Chunking接口的对象。这个对象用一个字符序列(CharSequence)指定文本的“块”。

chunk方法使用一个字符数组和数组中的索引来指定需要处理的文本部分。一个Chunking对象是这样返回的:

Chunking chunking = sentenceChunker.chunk( 
    paragraph.toCharArray(),0, paragraph.length()); 

我们将使用Chunking对象有两个目的。首先,我们将使用它的chunkSet方法返回一组Chunk对象。然后,我们将获得一个包含所有句子的字符串:

Set<Chunk> sentences = chunking.chunkSet(); 
String slice = chunking.charSequence().toString();

一个Chunk对象存储句子边界的字符偏移量。我们将结合使用它的startend方法来显示句子,如下面的代码所示。每个元素和句子都包含句子的边界。我们使用这些信息来显示切片中的每个句子:

for (Chunk sentence : sentences) { 
    System.out.println("[" + slice.substring(sentence.start(), 
       sentence.end()) + "]"); 
} 

以下是输出。但是,对于以省略号结尾的句子,它仍然存在问题,因此在处理文本之前,在最后一句的末尾添加了一个句点。

    [When determining the end of sentences we need to consider several factors.]
    [Sentences may end with exclamation marks!]
    [Or possibly questions marks?]
    [Within sentences we may find numbers like 3.14159, abbreviations such as found in Mr. Smith, and possibly ellipses either within a sentence ..., or at the end of a sentence....]

尽管IndoEuropeanSentenceModel类对于英语文本相当适用,但对于专业文本可能并不总是适用。在下一节中,我们将检查MedlineSentenceModel类的使用,它已经被训练来处理医学文本。

使用 MedlineSentenceModel 类

LingPipe 语句模型使用的是 MEDLINE ,这是一个生物医学文献的大集合。这个集合以 XML 格式存储,由美国国家医学图书馆(【www.nlm.nih.gov/】??)维护。

LingPipe 使用它的MedlineSentenceModel类来执行 SBD。这个模型已经针对 MEDLINE 数据进行了训练。它使用简单的文本,并将其标记为标记和空白。然后使用 MEDLINE 模型来查找文本的句子。

在下面的例子中,我们将使用来自www.ncbi.nlm.nih.gov/pmc/articles/PMC3139422/的一段话来演示模型的使用,如这里所声明的:

paragraph = "HepG2 cells were obtained from the American Type 
 Culture "  
    + "Collection (Rockville, MD, USA) and were used only until "  
    + "passage 30\. They were routinely grown at 37°C in Dulbecco's " 
    + "modified Eagle's medium (DMEM) containing 10 % fetal bovine " 
    + "serum (FBS), 2 mM glutamine, 1 mM sodium pyruvate, and 25 " 
    + "mM glucose (Invitrogen, Carlsbad, CA, USA) in a humidified " 
    + "atmosphere containing 5% CO2\. For precursor and 13C-sugar "  
    + "experiments, tissue culture treated polystyrene 35 mm " 
    + "dishes (Corning Inc, Lowell, MA, USA) were seeded with 2 " 
    + "× 106 cells and grown to confluency in DMEM."; 

下面的代码基于SentenceChunker类,如前一节所示。不同之处在于MedlineSentenceModel类的使用:

TokenizerFactory tokenizerfactory = 
     IndoEuropeanTokenizerFactory.INSTANCE; 
MedlineSentenceModel sentenceModel = new 
     MedlineSentenceModel(); 
SentenceChunker sentenceChunker =  
    new SentenceChunker(tokenizerfactory, 
 sentenceModel); 
     = sentenceChunker.chunk( 
    paragraph.toCharArray(), 0, paragraph.length()); 
Set<Chunk> sentences = chunking.chunkSet(); 
String slice = chunking.charSequence().toString(); 
for (Chunk sentence : sentences) { 
    System.out.println("[" 
        + slice.substring(sentence.start(), 
 sentence.end())  
        + "]"); 
} 

输出如下所示:

    [HepG2 cells were obtained from the American Type Culture Collection (Rockville, MD, USA) and were used only until passage 30.]
    [They were routinely grown at 37°C in Dulbecco's modified Eagle's medium (DMEM) containing 10 % fetal bovine serum (FBS), 2 mM glutamine, 1 mM sodium pyruvate, and 25 mM glucose (Invitrogen, Carlsbad, CA, USA) in a humidified atmosphere containing 5% CO2.]
    [For precursor and 13C-sugar experiments, tissue culture treated polystyrene 35 mm dishes (Corning Inc, Lowell, MA, USA) were seeded with 2 × 106 cells and grown to confluency in DMEM.] 

当针对医学文本执行时,该模型将比其他模型执行得更好。

训练句子检测器模型

我们将用 OpenNLP 的SentenceDetectorME类来说明训练过程。这个类有一个静态的train方法,使用在文件中找到的例句。该方法返回一个模型,该模型通常被序列化为一个文件以供以后使用。

模型使用特殊的带注释的数据来清楚地指定句子的结束位置。通常,一个大文件被用来为训练目的提供一个好的样本。该文件的一部分用于训练目的,其余部分用于在模型被训练后对其进行验证。

OpenNLP 使用的训练文件每行包含一句话。通常至少需要 10 到 20 个例句来避免处理错误。为了演示这个过程,我们将使用一个名为sentence.train的文件。它由儒勒·凡尔纳的第五章《海底两万里》组成。这本书的正文可以在 www.gutenberg.org/files/164/1…找到。该文件可以从 https://github . com/packt publishing/Natural-Language-Processing-with Java-Second-Edition下载,也可以从本书的 GitHub 资源库下载。

一个FileReader对象用于打开文件。这个对象被用作PlainTextByLineStream构造函数的参数。产生的流由文件中每行的一个字符串组成。这被用作SentenceSampleStream构造函数的参数,它将句子字符串转换成SentenceSample对象。这些对象保存每个句子的开始索引。这个过程如下所示,其中语句被包含在一个try块中,以处理这些语句可能抛出的异常:

try { 
    ObjectStream<String> lineStream = new PlainTextByLineStream( 
        new FileReader("sentence.train")); 
    ObjectStream<SentenceSample> sampleStream 
        = new SentenceSampleStream(lineStream); 
    ... 
    } catch (FileNotFoundException ex) { 
        ex.printStackTrace();
        // Handle exception 
    } catch (IOException ex) { 
        ex.printStackTrace(); 
        // Handle exception 
} 

现在,train方法可以这样使用:

SentenceModel model = SentenceDetectorME.train("en", 
     sampleStream, true, 
    null, TrainingParameters.defaultParams());

该方法的输出是经过训练的模型。下表详细列出了该方法的参数:

| 参数 | 意为 | | "en" | 指定 文本的语言是英语 | | sampleStream | 训练文本流 | | true | 指定是否应该使用显示的结束标记 | | null | 缩略语词典 | | TrainingParameters.defaultParams() | 指定应使用默认训练参数 |

在下面的序列中,OutputStream被创建并用于将模型保存在modelFile文件中。这使得模型可以在其他应用程序中重复使用:

OutputStream modelStream = new BufferedOutputStream( 
    new FileOutputStream("modelFile")); 
model.serialize(modelStream); 

这个过程的输出如下。为了节省空间,这里没有显示所有的迭代。默认情况下,将索引事件截止到5并将迭代次数截止到 100:

    Indexing events using cutoff of 5

        Computing event counts...  done. 93 events
        Indexing...  done.
    Sorting and merging events... done. Reduced 93 events to 63.
    Done indexing.
    Incorporating indexed data for training...  
    done.
        Number of Event Tokens: 63
            Number of Outcomes: 2
          Number of Predicates: 21
    ...done.
    Computing model parameters ...
    Performing 100 iterations.
      1:  ... loglikelihood=-64.4626877920749    0.9032258064516129
      2:  ... loglikelihood=-31.11084296202819    0.9032258064516129
      3:  ... loglikelihood=-26.418795734248626    0.9032258064516129
      4:  ... loglikelihood=-24.327956749903198    0.9032258064516129
      5:  ... loglikelihood=-22.766489585258565    0.9032258064516129
      6:  ... loglikelihood=-21.46379347841989    0.9139784946236559
      7:  ... loglikelihood=-20.356036369911394    0.9139784946236559
      8:  ... loglikelihood=-19.406935608514992    0.9139784946236559
      9:  ... loglikelihood=-18.58725539754483    0.9139784946236559
     10:  ... loglikelihood=-17.873030559849326    0.9139784946236559
     ...
     99:  ... loglikelihood=-7.214933901940582    0.978494623655914
    100:  ... loglikelihood=-7.183774954664058    0.978494623655914

使用训练好的模型

然后,我们可以使用该模型,如下面的代码序列所示。这是基于使用 SentenceDetectorME 类一节中的所阐述的技术:

try (InputStream is = new FileInputStream( 
        new File(getModelDir(), "modelFile"))) { 
    SentenceModel model = new SentenceModel(is); 
    SentenceDetectorME detector = new 
     SentenceDetectorME(model); 
    String sentences[] = detector.sentDetect(paragraph); 
    for (String sentence : sentences) { 
        System.out.println(sentence); 
    } 
} catch (FileNotFoundException ex) { 
    // Handle exception 
} catch (IOException ex) { 
    // Handle exception 
} 

输出如下所示:

    When determining the end of sentences we need to consider several factors.
    Sentences may end with exclamation marks! Or possibly questions marks?
    Within sentences we may find numbers like 3.14159,
    abbreviations such as found in Mr.
    Smith, and possibly ellipses either within a sentence ..., or at the end of a sentence...

这个模型没有很好地处理最后一句话,这反映了样本文本和模型所针对的文本之间的不匹配。使用相关的培训数据很重要。否则,基于该输出的下游任务将受到影响。

使用 SentenceDetectorEvaluator 类评估模型

出于评估的目的,我们保留了样本文件的一部分,以便我们可以使用SentenceDetectorEvaluator类来评估模型。我们修改了sentence.train文件,提取了最后 10 个句子,并将它们放在一个名为evalSample的文件中。然后,我们使用这个文件来评估模型。在下面的例子中,我们重用了lineStreamsampleStream变量来创建一个基于文件内容的SentenceSample对象流:

lineStream = new PlainTextByLineStream(
     new FileReader("evalSample")); 
sampleStream = new SentenceSampleStream(lineStream); 

使用之前创建的SentenceDetectorME类变量detector创建了一个SentenceDetectorEvaluator类的实例。构造函数的第二个参数是一个SentenceDetectorEvaluationMonitor对象,我们在这里不使用它。于是,evaluate的方法就叫做:

SentenceDetectorEvaluator sentenceDetectorEvaluator 
    = new SentenceDetectorEvaluator(detector, null); 
sentenceDetectorEvaluator.evaluate(sampleStream); 

getFMeasure方法将返回FMeasure类的一个实例,它提供了模型质量的度量:

System.out.println(sentenceDetectorEvaluator.getFMeasure()); 

输出如下。精度是包含的正确实例的分数,而召回反映了模型的敏感性。F-measure 是一个结合了召回率和准确率的分数。从本质上来说,它反映了模型运行的好坏。对于标记化和 SBD 任务,最好将精度保持在 90%以上:

    Precision: 0.8181818181818182
    Recall: 0.9
    F-Measure: 0.8571428571428572

摘要

在这一章中,我们讨论了使句子检测成为一项困难任务的许多问题,例如由用于数字和缩写的句点引起的问题。省略号和内嵌引号的使用也会有问题。

Java 提供了一些技术来检测句子的结尾。我们看到了如何使用正则表达式和BreakIterator类。这些技巧对于简单的句子很有用,但是对于更复杂的句子就没那么好用了。

还演示了各种 NLP APIs 的使用。其中一些基于规则处理文本,而另一些使用模型。我们还演示了如何训练和评估模型。

在下一章,第四章、查找人和事,你将学习如何使用文本查找人和事。

四、搜索人和事物

寻找人和事物的过程称为命名实体识别()。诸如人和地点之类的实体与具有名称的类别相关联,这些名称标识它们是什么。一个命名的类别可以简单到。常见的实体类型包括以下几种:

*** 人

  • 位置
  • 组织
  • 金钱
  • 时间
  • 资源定位符

在文档中查找名称、位置和各种东西是重要且有用的 NLP 任务。它们被用在许多地方,例如进行简单的搜索、处理查询、解析引用、消除文本的歧义以及寻找文本的含义。例如,NER 有时只对那些属于单一类别的实体感兴趣。使用类别,搜索可以被隔离到那些项目类型。其他 NLP 任务使用 NER,例如在词性 ( 词性)标签中以及在执行交叉引用任务中。

NER 进程涉及两项任务:

  • 实体检测
  • 实体分类

检测涉及找到文本中实体的位置。一旦找到它,确定发现了什么类型的实体是很重要的。在完成这两项任务后,结果可用于解决其他任务,如搜索和确定文本的含义。例如,任务可能包括从电影或书评中识别姓名,以及帮助查找可能感兴趣的其他电影或书籍。提取位置信息可以帮助提供对附近服务的参考。

我们将在本章中讨论以下主题:

  • 为什么 NER 很难?
  • 姓名识别技术
  • 对 NER 使用正则表达式
  • 使用 NLP APIs
  • 使用 NER 注记工具构建新数据集
  • 训练模型

为什么 NER 很难?

像许多 NLP 任务一样,NER 并不总是简单的。虽然文本的标记化将揭示其组成部分,但理解它们是什么可能是困难的。由于语言的模糊性,使用专有名词并不总是有效的。例如,Penny 和 Faith 虽然是有效的名称,但也可以分别用于度量货币和信仰。我们还可以找到像乔治亚这样的词,它们被用作一个国家、一个州和一个人的名字。我们也不能列出所有的人或地方或实体,因为它们不是预先定义的。考虑下面两个简单的句子:

  • 现在工作更难找了
  • 乔布斯说点总是会连接在一起的

在这两句话中,乔布斯似乎是一个实体,但他们并不相关,在第二句话中,它甚至不是一个实体。我们需要使用一些复杂的技术来检查实体在上下文中的出现。句子可能以不同的方式使用同一个实体的名称。比方说,IBM 和国际商业机器公司;这两个术语在文本中用来指同一个实体,但对 NER 来说,这是一个挑战。再举一个例子:铃木和日产可能被 NER 解释为人名,而不是公司名。

有些短语很有挑战性。考虑短语“*大都会会展大厅”*可能包含本身是有效实体的单词。因此,当领域众所周知时,可以很容易地识别实体列表,并且也很容易实现。

NER 通常应用于句子级别,否则短语很容易连接句子,导致实体的错误识别。举下面两句话为例:

鲍勃去了南方。达科塔去了西部。”

如果我们忽略了句子的边界,那么我们可能会无意中找到南达科他州的位置实体。

URL、电子邮件地址和专用号码等专用文本可能很难隔离。如果我们必须考虑实体形式的变化,这种识别就变得更加困难。例如,电话号码是否使用括号?是用破折号,句号,还是其他字符来分隔它的各个部分?我们需要考虑国际电话号码吗?

这些因素促成了对良好 NER 技术的需求。

姓名识别技术

有许多可用的 NER 技术。有些使用正则表达式,有些基于预定义的字典。正则表达式有很强的表达能力,可以隔离实体。实体名称的字典可以与文本的标记进行比较以找到匹配。

另一种常见的 NER 方法是使用经过训练的模型来检测它们的存在。这些模型依赖于我们正在寻找的实体类型和目标语言。适用于一个领域(如网页)的模型可能不适用于另一个领域(如医学期刊)。

当模型被训练时,它使用一个带注释的文本块,该文本块标识感兴趣的实体。要衡量模型的训练效果,可以使用以下几种方法:

  • 精度:与评估数据中发现的跨度完全匹配的实体的百分比
  • Recall :这是语料库中定义的在相同位置找到的实体的百分比
  • 性能指标:由 *F1 = 2 精度召回/(召回+精度)*给出的精度和召回的调和平均值

当我们讨论模型的评估时,我们将使用这些方法。

NER 也被称为实体识别和实体分块。组块是对文本的分析,以识别其部分,如名词、动词或其他成分。作为人类,我们倾向于把一个句子分成不同的部分。这些部分形成了一个结构,我们用它来确定它的意义。NER 进程将创建文本跨度,如英国女王。然而,在这些跨度内可能有其他实体,例如英国

NER 系统使用不同的技术构建,可分为以下几类:

  • 基于规则的方法使用领域专家制定的规则来识别实体。基于规则的系统解析文本并生成解析树或其他抽象格式。它可以是使用一组单词的基于列表的查找,也可以是需要深入了解实体识别的语言学方法。

  • 机器学习方法使用带有统计模型的基于模式的学习,其中名词被识别和分类。机器学习又可以分为三种不同的类型:

    • 监督学习使用带标签的数据来建立模型
    • 半监督学习使用标记数据以及其他信息来建立模型
    • 无监督学习使用未标记的数据,并从输入中学习
  • NE 提取通常用于从网页中提取数据。它不仅学习,而且为 NER 形成或建立一个列表。

列表和正则表达式

一种技术是使用标准实体列表和正则表达式来标识命名实体。命名实体有时被称为专有名词。标准实体列表可以是州、常用名称、月份或经常引用的位置的列表。地名词典是包含与地图一起使用的地理信息的列表,提供了位置相关实体的来源。然而,维护这样的列表可能很耗时。它们也可以是特定于语言和地区的。对列表进行更改可能会很繁琐。我们将在本章后面的部分使用 ExactDictionaryChunker 类演示这种方法。

正则表达式在识别实体时很有用。它们强大的语法在许多情况下提供了足够的灵活性,可以准确地分离出感兴趣的实体。然而,这种灵活性也会使它们难以理解和维护。我们将在本章中演示几种正则表达式方法。

统计分类器

统计分类器确定一个单词是实体的开始,还是实体的继续,或者根本不是实体。样本文本被标记以隔离实体。一旦开发了分类器,就可以针对不同问题领域的不同数据集对其进行训练。这种方法的缺点是需要有人对样本文本进行注释,这是一个耗时的过程。此外,它还依赖于域。

我们将考察几种表演 NER 的方法。首先,我们将从解释如何使用正则表达式来标识实体开始。

对 NER 使用正则表达式

正则表达式可用于标识文档中的实体。我们将研究两种通用方法:

  • 第一种使用 Java 支持的正则表达式。在实体相对简单且形式一致的情况下,这可能很有用。
  • 第二种方法使用专门设计用于正则表达式的类。为了演示这一点,我们将使用 LingPipe 的RegExChunker类。

当使用正则表达式时,避免重新发明轮子是有利的。预定义和经过测试的表达式有很多来源。一个这样的图书馆可以在 regexlib.com/Default.asp… 找到。在我们的例子中,我们将使用这个库中的几个正则表达式。

为了测试这些方法的效果,我们将在大多数示例中使用以下文本:

private static String regularExpressionText 
    = "He left his email address (rgb@colorworks.com) and his " 
    + "phone number,800-555-1234\. We believe his current address " 
    + "is 100 Washington Place, Seattle, CO 12345-1234\. I " 
    + "understand you can also call at 123-555-1234 between " 
    + "8:00 AM and 4:30 most days. His URL is http://example.com " 
    + "and he was born on February 25, 1954 or 2/25/1954.";

使用 Java 的正则表达式查找实体

为了演示如何使用这些表达式,我们将从几个简单的例子开始。最初的例子从下面的声明开始。这是一个简单的表达式,用于识别特定类型的电话号码:

String phoneNumberRE = "\\d{3}-\\d{3}-\\d{4}"; 

我们将使用下面的代码来测试我们的简单表达式。Pattern类的compile方法获取一个正则表达式,并将其编译成一个Pattern对象。然后可以对目标文本执行它的matcher方法,返回一个Matcher对象。这个对象允许我们重复识别正则表达式匹配:

Pattern pattern = Pattern.compile(phoneNumberRE); 
Matcher matcher = pattern.matcher(regularExpressionText); 
while (matcher.find()) { 
    System.out.println(matcher.group() + " [" + matcher.start() 
        + ":" + matcher.end() + "]"); 
} 

当匹配发生时,find方法将返回true。它的group方法返回匹配表达式的文本。它的startend方法给我们匹配文本在目标文本中的位置。

执行时,我们将得到以下输出:

    800-555-1234 [68:80]
    123-555-1234 [196:208]

许多其他正则表达式也可以以类似的方式使用。下表列出了这些选项。第三列是在前面的代码序列中使用相应的正则表达式时产生的输出:

实体类型正则表达式输出
统一资源定位器`\b(https?|ftp|file|ldap)://[-A-Za-z0-9+&@#/%?
=_|!:,.;]*[-A-Za-z0-9+&@#/%=_|]`http://example.com [256:274]
邮政区码[0-9]{5}(\\-?[0-9]{4})?12345-1234 [150:160]
电子邮件[a-zA-Z0-9'._%+-]+@(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,4}rgb@colorworks.com [27:45]
时间(([0-1]?[0-9])&#124;([2][0-3])):([0-5]?[0-9])(:([0-5]?[0-9]))?8:00 [217:221]``4:30 [229:233]
日期`((0?[13578]|10|12)(-|\/)
(([1-9])|(0[1-9])|([12])([0-9]?)|(3[01]?))(-|\/)
((19)([2-9])(\d{1})|(20)([01])(\d{1})|([8901])
(\d{1}))|(0?[2469]|11)(-|\/)(([1-9])
|(0[1-9])|([12])([0-9]?)|(3[0]?))
(-|\/)((19)([2-9])(\d{1})|(20)([01])
(\d{1})|([8901])(\d{1})))`2/25/1954 [315:324]

我们还可以使用许多其他的正则表达式。然而,这些例子说明了基本的技术。正如日期正则表达式所展示的,其中一些可能相当复杂。

正则表达式遗漏一些实体并将其他非实体误报为实体是很常见的。例如,我们可以用以下表达式替换文本:

regularExpressionText =  
    "(888)555-1111 888-SEL-HIGH 888-555-2222-J88-W3S"; 

执行代码将返回以下内容:

    888-555-2222 [27:39]

它漏掉了前两个电话号码,并将零件号误报为电话号码。

我们还可以使用|操作符一次搜索多个正则表达式。在下面的语句中,使用该运算符组合了三个正则表达式。它们是使用上表中的相应条目声明的:

Pattern pattern = Pattern.compile(phoneNumberRE + "|"  
    + timeRE + "|" + emailRegEx); 

当使用前一节开始时定义的原始regularExpressionText文本执行时,我们得到以下输出:

    rgb@colorworks.com [27:45]
    800-555-1234 [68:80]
    123-555-1234 [196:208]
    8:00 [217:221]
    4:30 [229:233]

使用 LingPipe 的 RegExChunker 类

RegExChunker类使用块来查找文本中的实体。该类使用正则表达式来表示实体。它的chunk方法返回一个Chunking对象,可以像我们在前面的例子中那样使用它。

RegExChunker类的构造函数有三个参数:

  • 这是一个正则表达式
  • String:这是一种实体或类别
  • double:分数的值

在下面的例子中,我们将使用一个表示时间的正则表达式来演示这个类。正则表达式与本章前面的使用 Java 的正则表达式查找实体一节中使用的相同。然后创建了Chunker实例:

String timeRE =  
   "(([0-1]?[0-9])|([2][0-3])):([0-5]?[0-9])(:([0-5]?[0-9]))?"; 
       Chunker chunker = new RegExChunker(timeRE,"time",1.0); 

使用了Chunk方法和displayChunkSet方法,如下所示:

Chunking chunking = chunker.chunk(regularExpressionText); 
Set<Chunk> chunkSet = chunking.chunkSet(); 
displayChunkSet(chunker, regularExpressionText); 

下面的代码段显示了displayChunkSet方法。chunkSet方法返回一组Chunk实例的集合。我们可以使用各种方法来显示块的特定部分:

public void displayChunkSet(Chunker chunker, String text) { 
    Chunking chunking = chunker.chunk(text); 
    Set<Chunk> set = chunking.chunkSet(); 
    for (Chunk chunk : set) { 
        System.out.println("Type: " + chunk.type() + " Entity: [" 
             + text.substring(chunk.start(), chunk.end()) 
             + "] Score: " + chunk.score()); 
    } 
} 

输出如下所示:

    Type: time Entity: [8:00] Score: 1.0
    Type: time Entity: [4:30] Score: 1.0+95

或者,我们可以声明一个简单的类来封装正则表达式,这有助于在其他情况下重用。接下来,声明了TimeRegexChunker类,它支持时间实体的标识:

public class TimeRegexChunker extends RegExChunker { 
    private final static String TIME_RE =  
      "(([0-1]?[0-9])|([2][0-3])):([0-5]?[0-9])(:([0-5]?[0-9]))?"; 
    private final static String CHUNK_TYPE = "time"; 
    private final static double CHUNK_SCORE = 1.0; 

    public TimeRegexChunker() { 
        super(TIME_RE,CHUNK_TYPE,CHUNK_SCORE); 
    } 
} 

要使用这个类,用下面的声明替换这个部分的初始声明chunker:

Chunker chunker = new TimeRegexChunker(); 

输出将和以前一样。

使用 NLP APIs

我们将使用 OpenNLP、Stanford API 和 LingPipe 演示 NER 过程。每种方法都提供了替代技术,通常可以很好地识别文本中的实体。以下声明将作为演示 API 的示例文本:

String sentences[] = {"Joe was the last person to see Fred. ", 
  "He saw him in Boston at McKenzie's pub at 3:00 where he " 
  + " paid $2.45 for an ale. ", 
  "Joe wanted to go to Vermont for the day to visit a cousin who " 
  + "works at IBM, but Sally and he had to look for Fred"}; 

为 NER 使用 OpenNLP

我们将使用 OpenNLP API 演示使用TokenNameFinderModel类来执行 NLP。此外,我们将演示如何确定被识别的实体是正确的概率。

一般的方法是将文本转换成一系列标记化的句子,使用适当的模型创建一个TokenNameFinderModel类的实例,然后使用find方法识别文本中的实体。

下面的例子演示了TokenNameFinderModel类的用法。我们一开始会用一个简单句,然后用多个句子。这句话是这样定义的:

String sentence = "He was the last person to see Fred."; 

我们将使用在en-token.binen-ner-person.bin文件中找到的模型,分别用于标记器和名称查找器模型。这些文件的InputStream对象是使用 try-with-resources 块打开的,如下所示:

try (InputStream tokenStream = new FileInputStream( 
        new File(getModelDir(), "en-token.bin")); 
        InputStream modelStream = new FileInputStream( 
            new File(getModelDir(), "en-ner-person.bin"));) { 
    ... 

} catch (Exception ex) { 
    // Handle exceptions 
} 

try块中,创建了TokenizerModelTokenizer对象:

    TokenizerModel tokenModel = new TokenizerModel(tokenStream); 
    Tokenizer tokenizer = new TokenizerME(tokenModel); 

接下来,使用person模型创建一个NameFinderME类的实例:

TokenNameFinderModel entityModel =  
    new TokenNameFinderModel(modelStream); 
NameFinderME nameFinder = new NameFinderME(entityModel); 

我们现在可以使用tokenize方法来标记文本,使用find方法来识别文本中的人。find方法将使用标记化的String数组作为输入,并返回一个Span对象的数组,如下所示:

String tokens[] = tokenizer.tokenize(sentence); 
Span nameSpans[] = nameFinder.find(tokens);

我们讨论了第三章、找句子中的Span类。您可能还记得,这个类保存了找到的实体的位置信息。实际的字符串实体仍然在tokens数组中:

下面的for语句显示在句子中找到的人。它的位置信息和人显示在不同的行上:

for (int i = 0; i < nameSpans.length; i++) { 
    System.out.println("Span: " + nameSpans[i].toString()); 
    System.out.println("Entity: " 
        + tokens[nameSpans[i].getStart()]); 
} 

输出如下所示:

    Span: [7..9) person
    Entity: Fred

我们经常会用到多个句子。为了演示这一点,我们将使用之前定义的sentences字符串数组。先前的for语句被替换为以下序列。对每个句子调用tokenize方法,然后显示实体信息,就像前面一样:

for (String sentence : sentences) { 
    String tokens[] = tokenizer.tokenize(sentence); 
    Span nameSpans[] = nameFinder.find(tokens); 
    for (int i = 0; i < nameSpans.length; i++) { 
        System.out.println("Span: " + nameSpans[i].toString()); 
        System.out.println("Entity: "  
            + tokens[nameSpans[i].getStart()]); 
    } 
    System.out.println(); 
} 

输出如下。在检测到的两个人之间有一个额外的空白行,因为第二个句子不包含person:

    Span: [0..1) person
    Entity: Joe
    Span: [7..9) person
    Entity: Fred

    Span: [0..1) person
    Entity: Joe
    Span: [19..20) person
    Entity: Sally
    Span: [26..27) person
    Entity: Fred

确定实体的准确性

TokenNameFinderModel识别文本中的实体时,它计算该实体的概率。我们可以使用probs方法访问这些信息,如下面的代码行所示。这个方法返回一个 doubles 数组,它对应于nameSpans数组的元素:

double[] spanProbs = nameFinder.probs(nameSpans); 

在使用find方法后,立即将该语句添加到前面的示例中。然后,在嵌套的for语句的末尾添加以下语句:

System.out.println("Probability: " + spanProbs[i]); 

当执行这个示例时,您将获得以下输出。概率字段反映了实体分配的置信度。对于第一个实体,模型有 80.529%的把握认为Joe是一个person:

    Span: [0..1) person
    Entity: Joe
    Probability: 0.8052914774025202
    Span: [7..9) person
    Entity: Fred
    Probability: 0.9042160889302772

    Span: [0..1) person
    Entity: Joe
    Probability: 0.9620970782763985
    Span: [19..20) person
    Entity: Sally
    Probability: 0.964568603518126
    Span: [26..27) person
    Entity: Fred
    Probability: 0.990383039618594

使用其他实体类型

OpenNLP 支持不同的库,如下表所列。这些模型可以从 opennlp.sourceforge.net/models-1.5/…](opennlp.sourceforge.net/models-1.5/…](opennlp.sourceforge.net/models-1.5/) en前缀指定英语为语言,ner表示该型号适用于 NER:

| 英国发现者型号 | 文件名 | | 位置名称查找器模型 | en-ner-location.bin | | 货币名称查找器模型 | en-ner-money.bin | | 组织名称查找器模型 | en-ner-organization.bin | | 百分比名称查找器模型 | en-ner-percentage.bin | | 人名搜索模型 | en-ner-person.bin | | 时间名称查找器模型 | en-ner-time.bin |

如果我们将语句修改为使用不同的模型文件,我们可以看到它们是如何对照例句工作的:

InputStream modelStream = new FileInputStream( 
    new File(getModelDir(), "en-ner-time.bin"));) { 

下表显示了各种输出:

型号输出
en-ner-location.binSpan: [4..5) location``Entity: Boston``Probability: 0.8656908776583051``Span: [5..6) location``Entity: Vermont``Probability: 0.9732488014011262
en-ner-money.binSpan: [14..16) money``Entity: 2.45``Probability: 0.7200919701507937
en-ner-organization.binSpan: [16..17) organization``Entity: IBM``Probability: 0.9256970736336729
en-ner-time.bin模型无法检测此文本序列中的时间

当使用en-ner-money.bin模型时,早期代码序列中的令牌数组中的索引必须增加 1。否则,返回的都是美元符号。

模型在示例文本中找不到时间实体。这说明模型没有足够的信心在文本中找到任何时间实体。

处理多个实体类型

我们还可以同时处理多个实体类型。这包括基于循环中的每个模型创建NameFinderME类的实例,并将模型应用于每个句子,在发现实体时跟踪它们。

我们将用下面的例子来说明这个过程。它需要重写前面的try块,以在块中创建InputStream实例,如下所示:

try { 
    InputStream tokenStream = new FileInputStream( 
        new File(getModelDir(), "en-token.bin")); 
    TokenizerModel tokenModel = new TokenizerModel(tokenStream); 
    Tokenizer tokenizer = new TokenizerME(tokenModel); 
    ... 
} catch (Exception ex) { 
    // Handle exceptions 
} 

try块中,我们将定义一个String数组来保存模型文件的名称。如此处所示,我们将对人员、位置和组织使用模型:

String modelNames[] = {"en-ner-person.bin",  
    "en-ner-location.bin", "en-ner-organization.bin"}; 

创建一个ArrayList实例来保存被发现的实体:

ArrayList<String> list = new ArrayList(); 

一个foreach语句用于一次加载一个模型,然后创建一个NameFinderME类的实例:

for(String name : modelNames) { 
    TokenNameFinderModel entityModel = new TokenNameFinderModel( 
        new FileInputStream(new File(getModelDir(), name))); 
    NameFinderME nameFinder = new NameFinderME(entityModel); 
    ... 
} 

以前,我们并不试图识别实体出现在哪个句子中。这并不难做到,但是我们需要使用一个简单的for语句而不是foreach语句来跟踪句子索引。下面的例子显示了这一点,前面的例子被修改为使用整数变量index来保存句子。否则,代码的工作方式与前面相同:

for (int index = 0; index < sentences.length; index++) { 
    String tokens[] = tokenizer.tokenize(sentences[index]); 
    Span nameSpans[] = nameFinder.find(tokens); 
    for(Span span : nameSpans) { 
        list.add("Sentence: " + index 
            + " Span: " + span.toString() + " Entity: " 
            + tokens[span.getStart()]); 
    } 
} 

然后显示发现的实体:

for(String element : list) { 
    System.out.println(element); 
} 

输出如下所示:

Sentence: 0 Span: [0..1) person Entity: Joe
Sentence: 0 Span: [7..9) person Entity: Fred
Sentence: 2 Span: [0..1) person Entity: Joe
Sentence: 2 Span: [19..20) person Entity: Sally
Sentence: 2 Span: [26..27) person Entity: Fred
Sentence: 1 Span: [4..5) location Entity: Boston
Sentence: 2 Span: [5..6) location Entity: Vermont
Sentence: 2 Span: [16..17) organization Entity: IBM  

为 NER 使用 Stanford API

我们将演示CRFClassifier类,因为它将用于执行 NER。这个类实现了所谓的线性链条件随机场 ( CRF )序列模型。

为了演示CRFClassifier类的使用,我们将从分类器文件字符串的声明开始,如下所示:

String model = getModelDir() +  
    "\\english.conll.4class.distsim.crf.ser.gz"; 

然后使用模型创建分类器:

CRFClassifier<CoreLabel> classifier = 
    CRFClassifier.getClassifierNoExceptions(model);

classify方法接受一个表示要处理的文本的字符串。要使用sentences文本,我们需要将其转换成一个简单的字符串:

String sentence = ""; 
for (String element : sentences) { 
    sentence += element; 
} 

然后将classify方法应用于文本:

List<List<CoreLabel>> entityList = classifier.classify(sentence); 

返回CoreLabel对象的List实例的List实例。返回的对象是一个包含另一个列表的列表。包含的列表是CoreLabel对象的一个List实例。CoreLabel类表示附加了附加信息的单词。列表包含这些单词的列表。在下面代码序列的外部 for-each 语句中,引用变量internalList代表文本中的一个句子。在内部 for-each 语句中,显示了内部列表中的每个单词。word方法返回单词,get方法返回单词的类型。

然后显示单词及其类型:

for (List<CoreLabel> internalList: entityList) { 
    for (CoreLabel coreLabel : internalList) { 
        String word = coreLabel.word(); 
        String category = coreLabel.get( 
            CoreAnnotations.AnswerAnnotation.class); 
        System.out.println(word + ":" + category); 
    } 
} 

部分输出如下。它已被截断,因为显示了每个单词。O代表另一类:

    Joe:PERSON
    was:O
    the:O
    last:O
    person:O
    to:O
    see:O
    Fred:PERSON
    .:O
 He:O ... look:O for:O Fred:PERSON

要过滤掉不相关的单词,请用以下语句替换println语句。这将消除其他类别:

if (!"O".equals(category)) { 
    System.out.println(word + ":" + category); 
} 

现在输出更简单了:

Joe:PERSON
Fred:PERSON
Boston:LOCATION
McKenzie:PERSON
Joe:PERSON
Vermont:LOCATION
IBM:ORGANIZATION
Sally:PERSON
Fred:PERSON  

为 NER 使用 LingPipe

我们之前在本章前面的为 NER 使用正则表达式一节中演示了使用正则表达式来使用 LingPipe。在这里,我们将演示如何使用命名实体模型和ExactDictionaryChunker类来执行 NER 分析。

使用 LingPipe 的命名实体模型

LingPipe 有几个命名实体模型,我们可以使用它们进行分块。这些文件由一个序列化对象组成,可以从文件中读取该对象,然后将其应用于文本。这些对象实现了Chunker接口。分块过程产生一系列的Chunking对象,这些对象识别感兴趣的实体。

下表列出了 NER 的型号。这些模型可以从alias-i.com/lingpipe/web/models.html下载:

| 型 | 文集 | 文件 | | 英语新闻 | MUC-6 | 新新闻。查里斯克林春克 | | 英语基因 | 基因标签 | 新生物基因标签。HmmChunker | | 英语基因组学 | 妖怪 | 生物基因学。TokenShapeChunker |

我们将使用在ne-en-news-muc6.AbstractCharLmRescoringChunker文件中找到的模型来演示如何使用这个类。 我们将从一个try...catch块开始处理异常,如下例所示。该文件被打开并与AbstractExternalizable类的静态readObject方法一起使用,以创建一个Chunker类的实例。该方法 将读入序列化模型:

try { 
    File modelFile = new File(getModelDir(),  
        "ne-en-news-muc6.AbstractCharLmRescoringChunker"); 
     Chunker chunker = (Chunker)  
        AbstractExternalizable.readObject(modelFile); 
    ... 
} catch (IOException | ClassNotFoundException ex) { 
    // Handle exception 
} 

ChunkerChunking接口提供了处理一组文本块的方法。它的chunk方法返回一个实现Chunking实例的对象。以下序列显示了在文本的每个句子中找到的块,如下所示:

for (int i = 0; i < sentences.length; ++i) { 
    Chunking chunking = chunker.chunk(sentences[i]); 
    System.out.println("Chunking=" + chunking); 
} 

该序列的输出如下:

    Chunking=Joe was the last person to see Fred.  : [0-3:PERSON@-Infinity, 31-35:ORGANIZATION@-Infinity]
    Chunking=He saw him in Boston at McKenzie's pub at 3:00 where he paid $2.45 for an ale.  : [14-20:LOCATION@-Infinity, 24-32:PERSON@-Infinity]
    Chunking=Joe wanted to go to Vermont for the day to visit a cousin who works at IBM, but Sally and he had to look for Fred : [0-3:PERSON@-Infinity, 20-27:ORGANIZATION@-Infinity, 71-74:ORGANIZATION@-Infinity, 109-113:ORGANIZATION@-Infinity]

相反,我们可以使用Chunk类的方法来提取特定的信息,如下面的代码所示。我们将用下面的foreach语句替换前面的for语句。这调用了使用本章前面的 LingPipe 部分的 RegExChunker 类在中开发的displayChunkSet方法:

for (String sentence : sentences) { 
    displayChunkSet(chunker, sentence); 
} 

下面的输出显示了结果。但是,它并不总是与实体类型正确匹配:

Type: PERSON Entity: [Joe] Score: -Infinity
Type: ORGANIZATION Entity: [Fred] Score: -Infinity
Type: LOCATION Entity: [Boston] Score: -Infinity
Type: PERSON Entity: [McKenzie] Score: -Infinity
Type: PERSON Entity: [Joe] Score: -Infinity
Type: ORGANIZATION Entity: [Vermont] Score: -Infinity
Type: ORGANIZATION Entity: [IBM] Score: -Infinity
Type: ORGANIZATION Entity: [Fred] Score: -Infinity  

使用 ExactDictionaryChunker 类

ExactDictionaryChunker类提供了一种简单的方法来创建实体及其类型的字典,稍后可以用它在文本中找到它们。它使用一个MapDictionary对象存储条目,然后使用ExactDictionaryChunker类根据字典提取组块。

AbstractDictionary接口支持对实体、类别和分数的基本操作。该分数用于匹配过程。MapDictionaryTrieDictionary类实现了AbstractDictionary接口。TrieDictionary类使用字符 trie 结构存储信息。这种方法使用较少的内存,所以当内存有限时,这种方法工作得很好。在我们的例子中,我们将使用MapDictionary类。

为了说明这种方法,我们将从对MapDictionary类的声明开始:

private MapDictionary<String> dictionary;

字典将包含我们有兴趣寻找的实体。我们需要初始化模型,如下面的initializeDictionary方法所示。这里使用的DictionaryEntry构造函数接受三个参数:

  • String:实体的名称
  • String:实体的类别
  • Double:表示该实体的分数

在确定匹配时使用分数。一些实体被声明并添加到字典中:

private static void initializeDictionary() { 
    dictionary = new MapDictionary<String>(); 
    dictionary.addEntry( 
        new DictionaryEntry<String>("Joe","PERSON",1.0)); 
    dictionary.addEntry( 
        new DictionaryEntry<String>("Fred","PERSON",1.0)); 
    dictionary.addEntry( 
        new DictionaryEntry<String>("Boston","PLACE",1.0)); 
    dictionary.addEntry( 
        new DictionaryEntry<String>("pub","PLACE",1.0)); 
    dictionary.addEntry( 
        new DictionaryEntry<String>("Vermont","PLACE",1.0)); 
    dictionary.addEntry( 
        new DictionaryEntry<String>("IBM","ORGANIZATION",1.0)); 
    dictionary.addEntry( 
        new DictionaryEntry<String>("Sally","PERSON",1.0)); 
} 

一个ExactDictionaryChunker实例将使用这个字典。这里详细说明了ExactDictionaryChunker类的参数:

  • Dictionary<String>:包含实体的字典
  • 这是分块器使用的标记器
  • boolean:如果是true,分块器应该返回所有匹配
  • boolean:如果是true,匹配区分大小写

匹配可以重叠。例如,在短语第一国家银行中,实体银行可以单独使用,也可以与短语的其余部分结合使用。第三个参数是,boolean决定是否返回所有的匹配。

在下面的序列中,字典被初始化。然后,我们使用印欧标记器创建了一个ExactDictionaryChunker类的实例,在这里我们返回所有匹配项,并忽略标记的大小写:

initializeDictionary(); 
ExactDictionaryChunker dictionaryChunker 
    = new ExactDictionaryChunker(dictionary, 
        IndoEuropeanTokenizerFactory.INSTANCE, true, false); 

dictionaryChunker对象用于每个句子,如下面的代码序列所示。我们将使用displayChunkSet方法,正如在本章前面的中使用 的 RegExChunker 类所开发的:

for (String sentence : sentences) { 
    System.out.println("\nTEXT=" + sentence); 
    displayChunkSet(dictionaryChunker, sentence); 
} 

在执行时,我们得到以下输出:

TEXT=Joe was the last person to see Fred. 
Type: PERSON Entity: [Joe] Score: 1.0
Type: PERSON Entity: [Fred] Score: 1.0

TEXT=He saw him in Boston at McKenzie's pub at 3:00 where he paid $2.45 for an ale. 
Type: PLACE Entity: [Boston] Score: 1.0
Type: PLACE Entity: [pub] Score: 1.0

TEXT=Joe wanted to go to Vermont for the day to visit a cousin who works at IBM, but Sally and he had to look for Fred
Type: PERSON Entity: [Joe] Score: 1.0
Type: PLACE Entity: [Vermont] Score: 1.0
Type: ORGANIZATION Entity: [IBM] Score: 1.0
Type: PERSON Entity: [Sally] Score: 1.0
Type: PERSON Entity: [Fred] Score: 1.0  

这做得很好,但是为大量词汇创建字典需要很大的努力。

使用 NER 注记工具构建新数据集

有许多不同形式的注释工具。有些是独立的,可以在本地机器上配置或安装,有些是基于云的,有些是免费的,有些是付费的。在这一节中,我们将关注免费的注释工具,了解如何使用它们,并看看我们可以通过注释实现什么。

为了了解如何使用注释来创建数据集,我们将看看这些工具:

  • 顽童
  • 斯坦福注释者

brat 代表 brat 快速注释工具,可以在【brat.nlplab.org/index.html】…](brat.nlplab.org/index.html)…](brat.nlplab.org/installatio…:

Joe was the last person to see Fred. He saw him in Boston at McKenzie's pub at 3:00 where he paid $2.45 for an ale. Joe wanted to go to Vermont for the day to visit a cousin who works at IBM, but Sally and he had to look for Fred.

由于显示没有选择文件,使用标签键可以选择文件。我们将创建一个名为text1.txt的文本文件,其内容与我们在前面的例子中处理的内容相同:

它将显示text1.txt文件的内容:

要注释文档,首先我们必须登录:

登录后,选择您希望注释的任何单词,这将打开新的注释窗口,其中列出/配置了实体类型和事件类型。所有这些信息都存储并预配置在data/test目录下的annotation.conf文件中。您可以根据需要修改文件:

当我们继续选择文本时,注释将显示在文本上:

一旦保存,注释文件可以被发现为text1.ann [ Filename.ann ]。

另一个工具是斯坦福标注工具,可以从NLP . Stanford . edu/software/Stanford-manual-Annotation-tool-2004-05-16 . tar . gz下载。下载完成后,提取并双击annotator.jar,或者执行以下命令:

> java -jar annotator.jar

它将显示以下内容:

您可以打开任何文本文件,也可以编写内容并保存文件。我们在前面的注释示例中使用的文本将再次使用,只是为了展示如何使用斯坦福注释工具。

一旦内容可用,下一步就是创建标签。从“标记”菜单中,选择“添加标记”选项,这将打开“标记创建”窗口,如以下屏幕截图所示:

输入标签名称,然后单击确定。然后会要求您选择标签的颜色。它将在主窗口的右侧窗格中显示标记,如下面的屏幕截图所示:

同样,我们可以创建任意多的标签。一旦创建了标签,下一步就是注释文本。要注释文本,比如说,Joe,使用鼠标选择文本并点击右边的名称标签。它将向文本添加标记,如下所示:

同样,正如我们对 Joe 所做的那样,我们可以根据需要标记任何其他文本,并保存文件。还可以保存标签,以便在其他文本上重复使用。保存的文件是普通的文本文件,可以在任何文本编辑器中查看。

训练模型

我们将使用 OpenNLP 来演示如何训练一个模型。使用的培训文件必须:

  • 包含标记来区分实体
  • 每行一句话

我们将使用下面的模型文件,命名为en-ner-person.train:

<START:person> Joe <END> was the last person to see <START:person> Fred <END>.  
He saw him in Boston at McKenzie's pub at 3:00 where he paid $2.45 for an ale.  
<START:person> Joe <END> wanted to go to Vermont for the day to visit a cousin who works at IBM, but <START:person> Sally <END> and he had to look for <START:person> Fred <END>. 

这个例子中的几个方法能够抛出异常。这些语句将放在 try-with-resource 块中,如下所示,在这里创建了模型的输出流:

try (OutputStream modelOutputStream = new BufferedOutputStream( 
        new FileOutputStream(new File("modelFile")));) { 
    ... 
} catch (IOException ex) { 
    // Handle exception 
} 

在这个块中,我们使用PlainTextByLineStream类创建了一个OutputStream<String>对象。这个类的构造函数接受一个FileInputStream实例,并将每一行作为一个String对象返回。en-ner-person.train文件被用作输入文件,如下所示。UTF-8字符串是指所使用的编码序列:

ObjectStream<String> lineStream = new PlainTextByLineStream( 
    new FileInputStream("en-ner-person.train"), "UTF-8"); 

lineStream对象包含用描述文本中实体的标签注释的流。这些需要被转换成NameSample对象,以便模型可以被训练。这个转换是由NameSampleDataStream类执行的,如下所示。一个NameSample对象保存文本中实体的名称:

ObjectStream<NameSample> sampleStream =  
    new NameSampleDataStream(lineStream); 

train方法现在可以如下执行:

TokenNameFinderModel model = NameFinderME.train( 
    "en", "person",  sampleStream,  
    Collections.<String, Object>emptyMap(), 100, 5);

下表详细列出了该方法的参数:

| 参数 | 意为 | | "en" | 语言代码 | | "person" | 实体类型 | | sampleStream | 抽样资料 | | null | 资源 | | 100 | 迭代次数 | | 5 | 近路 |

然后,模型被序列化为输出文件:

model.serialize(modelOutputStream); 

这个序列的输出如下。为了节省空间,它被缩短了。提供了有关创建模型的基本信息:

    Indexing events using cutoff of 5

      Computing event counts...  done. 53 events
      Indexing...  done.
    Sorting and merging events... done. Reduced 53 events to 46.
    Done indexing.
    Incorporating indexed data for training...  
    done.
      Number of Event Tokens: 46
          Number of Outcomes: 2
        Number of Predicates: 34
    ...done.
    Computing model parameters ...
    Performing 100 iterations.
      1:  ... loglikelihood=-36.73680056967707  0.05660377358490566
      2:  ... loglikelihood=-17.499660626361216  0.9433962264150944
      3:  ... loglikelihood=-13.216835449617108  0.9433962264150944
      4:  ... loglikelihood=-11.461783667999262  0.9433962264150944
      5:  ... loglikelihood=-10.380239416084963  0.9433962264150944
      6:  ... loglikelihood=-9.570622475692486  0.9433962264150944
      7:  ... loglikelihood=-8.919945779143012  0.9433962264150944
    ...
     99:  ... loglikelihood=-3.513810438211968  0.9622641509433962
    100:  ... loglikelihood=-3.507213816708068  0.9622641509433962

评估模型

可以使用TokenNameFinderEvaluator类来评估模型。评估过程使用标记的样本文本来执行评估。 在这个简单的例子中,创建了一个名为en-ner-person.eval的文件,其中包含以下文本:

<START:person> Bill <END> went to the farm to see <START:person> Sally <END>.  
Unable to find <START:person> Sally <END> he went to town. 
There he saw <START:person> Fred <END> who had seen <START:person> Sally <END> at the book store with <START:person> Mary <END>. 

下面的代码用于执行评估。之前的模型被用作TokenNameFinderEvaluator构造函数的参数。基于评估文件创建一个NameSampleDataStream实例。TokenNameFinderEvaluator类的evaluate方法执行评估:

TokenNameFinderEvaluator evaluator =  
    new TokenNameFinderEvaluator(new NameFinderME(model));     
lineStream = new PlainTextByLineStream( 
    new FileInputStream("en-ner-person.eval"), "UTF-8"); 
sampleStream = new NameSampleDataStream(lineStream); 
evaluator.evaluate(sampleStream); 

为了确定模型与评估数据的配合程度,需要执行getFMeasure方法。然后显示结果:

FMeasure result = evaluator.getFMeasure(); 
System.out.println(result.toString()); 

以下输出显示了PrecisionRecallF-Measure。它表明找到的 50%的实体与评估数据完全匹配。Recall是在相同位置找到的语料库中定义的实体的百分比。性能度量是调和平均值,定义为 F1 = 2 精度召回/(召回+精度):

Precision: 0.5 Recall: 0.25 F-Measure: 0.3333333333333333  

为了创建更好的模型,数据集和评估集应该更大。这里的目的是展示用于训练和评估 POS 模型的基本方法。

摘要

NER 包括检测实体,然后对它们进行分类。常见的类别包括名称、位置和事物。这是许多应用程序用来支持搜索、解析引用和查找文本含义的一项重要任务。该流程经常用于下游任务。

我们研究了几种表演 NER 的技巧。正则表达式是核心 Java 类和 NLP APIs 都支持的一种方法。这种技术对许多应用程序都很有用,并且有大量的正则表达式库可用。

基于字典的方法也是可能的,并且对于某些应用程序来说效果很好。然而,它们有时需要相当大的努力来填充。我们使用 LingPipe 的MapDictionary类来说明这种方法。

经过训练的模型也可以用来执行 NER。我们检查了其中的几个,并演示了如何使用 OpenNLP NameFinderME类训练一个模型。这个过程与早期的培训过程非常相似。

在下一章,第五章,检测词类我们将学习如何检测名词、形容词、介词等词类。**