nlp-java-merge-1

60 阅读17分钟

Java 自然语言处理(二)

原文:annas-archive.org/md5/32c19ca17f77d5b3cedfd88d491a9f8e

译者:飞龙

协议:CC BY-NC-SA 4.0

第四章. 词语和标记的标注

本章我们将涵盖以下方法:

  • 有趣短语检测

  • 前景或背景驱动的有趣短语检测

  • 隐马尔可夫模型(HMM)——词性标注

  • N 最佳词标注

  • 基于置信度的标注

  • 训练词标注

  • 词语标注评估

  • 条件随机场(CRF)用于词/标记标注

  • 修改 CRF

介绍

本章的重点是词语和标记。像命名实体识别这样的常见提取技术,实际上已经编码成了这里呈现的概念,但这需要等到第五章,在文本中找到跨度 - Chunking时才能讲解。我们将从简单的寻找有趣的标记集开始,然后转向隐马尔可夫模型(HMM),最后介绍 LingPipe 中最复杂的组件之一——条件随机场(CRF)。和往常一样,我们会向你展示如何评估标注并训练你自己的标注器。

有趣短语检测

假设一个程序能够自动从一堆文本数据中找到有趣的部分,其中“有趣”意味着某个词或短语出现的频率高于预期。它有一个非常好的特性——不需要训练数据,而且适用于我们有标记的任何语言。你最常在标签云中看到这种情况,如下图所示:

有趣短语检测

上图展示了为lingpipe.com主页生成的标签云。然而,正如 Jeffery Zeldman 在www.zeldman.com/daily/0405d.shtml中指出的那样,标签云被认为是“互联网的穆雷发型”,因此如果你在网站上部署这样的功能,可能会站不住脚。

如何做到这一点……

要从一个包含迪士尼推文的小数据集中提取有趣短语,请执行以下步骤:

  1. 启动命令行并输入:

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar:lib/opencsv-2.4.jar com.lingpipe.cookbook.chapter4.InterestingPhrases
    
    
  2. 程序应该返回类似如下的结果:

    Score 42768.0 : Crayola Color 
    Score 42768.0 : Bing Rewards 
    Score 42768.0 : PassPorter Moms 
    Score 42768.0 : PRINCESS BATMAN 
    Score 42768.0 : Vinylmation NIB 
    Score 42768.0 : York City 
    Score 42768.0 : eternal damnation 
    Score 42768.0 : ncipes azules 
    Score 42768.0 : diventare realt 
    Score 42768.0 : possono diventare 
    ….
    Score 42768.0 : Pictures Releases 
    Score 42768.0 : SPACE MOUNTAIN 
    Score 42768.0 : DEVANT MOI 
    Score 42768.0 : QUOI DEVANT 
    Score 42768.0 : Lindsay Lohan 
    Score 42768.0 : EPISODE VII 
    Score 42768.0 : STAR WARS 
    Score 42768.0 : Indiana Jones 
    Score 42768.0 : Steve Jobs 
    Score 42768.0 : Smash Mouth
    
    
  3. 你还可以提供一个.csv文件,按照我们的标准格式作为参数,以查看不同的数据。

输出往往是令人既期待又无用的。所谓“既期待又无用”是指一些有用的短语出现了,但同时也有许多无趣的短语,这些短语你在总结数据中有趣的部分时根本不想要。在有趣的那一侧,我们能看到Crayola ColorLindsey LohanEpisode VII等。在垃圾短语的那一侧,我们看到ncipes azulespictures releases等。解决垃圾输出有很多方法——最直接的一步是使用语言识别分类器将非英语的内容过滤掉。

它是如何工作的……

在这里,我们将完整地浏览源代码,并通过解释性文字进行拆解:

package com.lingpipe.cookbook.chapter4;

import java.io.FileReader;
import java.io.IOException;
import java.util.List;
import java.util.SortedSet;
import au.com.bytecode.opencsv.CSVReader;
import com.aliasi.lm.TokenizedLM;
import com.aliasi.tokenizer.IndoEuropeanTokenizerFactory;
import com.aliasi.util.ScoredObject;

public class InterestingPhrases {
  static int TEXT_INDEX = 3;
  public static void main(String[] args) throws IOException {
    String inputCsv = args.length > 0 ? args[0] : "data/disney.csv";

在这里,我们看到路径、导入语句和main()方法。我们提供默认文件名或从命令行读取的三元运算符是最后一行:

List<String[]> lines = Util.readCsv(new File(inputCsv));
int ngramSize = 3;
TokenizedLM languageModel = new TokenizedLM(IndoEuropeanTokenizerFactory.INSTANCE, ngramSize);

在收集输入数据后,第一个有趣的代码构建了一个标记化的语言模型,这与第一章中使用的字符语言模型有显著不同,简单分类器。标记化语言模型操作的是由TokenizerFactory创建的标记,而ngram参数决定了使用的标记数,而不是字符数。TokenizedLM的一个微妙之处在于,它还可以使用字符语言模型来为它之前未见过的标记做出预测。请参见前景或背景驱动的有趣短语检测食谱,了解这一过程是如何在实践中运作的;除非在估算时没有未知标记,否则不要使用之前的构造器。此外,相关的 Javadoc 提供了更多的细节。在以下代码片段中,语言模型被训练:

for (String [] line: lines) {
  languageModel.train(line[TEXT_INDEX]);
}

接下来的相关步骤是创建搭配词:

int phraseLength = 2;
int minCount = 2;
int maxReturned = 100;
SortedSet<ScoredObject<String[]>> collocations = languageModel.collocationSet(phraseLength, minCount, maxReturned);

参数化控制短语的长度(以标记为单位);它还设置了短语出现的最小次数以及返回多少个短语。我们可以查看长度为 3 的短语,因为我们有一个存储 3-gram 的语言模型。接下来,我们将查看结果:

for (ScoredObject<String[]> scoredTokens : collocations) {
  double score = scoredTokens.score();
  StringBuilder sb = new StringBuilder();
  for (String token : scoredTokens.getObject()) {
    sb.append(token + " ");
  }
  System.out.printf("Score %.1f : ", score);
  System.out.println(sb);
}

SortedSet<ScoredObject<String[]>> 搭配词按得分从高到低排序。得分的直观理解是,当标记的共现次数超过其在训练数据中单独出现的频率时,给予更高的得分。换句话说,短语的得分取决于它们如何偏离基于标记的独立假设。请参阅 Javadoc alias-i.com/lingpipe/docs/api/com/aliasi/lm/TokenizedLM.html 获取准确的定义——一个有趣的练习是创建你自己的得分系统,并与 LingPipe 中的做法进行比较。

还有更多……

鉴于此代码接近可在网站上使用,因此值得讨论调优。调优是查看系统输出并根据系统的错误做出修改的过程。一些我们会立即考虑的修改包括:

  • 一个语言 ID 分类器,方便用来过滤非英语文本

  • 思考如何更好地标记化数据

  • 改变标记长度,以便在摘要中包含 3-gram 和 unigram

  • 使用命名实体识别来突出专有名词

前景或背景驱动的有趣短语检测

和之前的食谱一样,这个食谱也会找到有趣的短语,但它使用了另一种语言模型来判断什么是有趣的。亚马逊的统计不可能短语(SIP)就是这样运作的。你可以通过他们的官网www.amazon.com/gp/search-inside/sipshelp.html清晰了解:

“亚马逊的统计学上不太可能出现的短语,或称为“SIPs”,是《搜索内容!™》项目中书籍文本中最具辨识度的短语。为了识别 SIPs,我们的计算机扫描所有《搜索内容!》项目中的书籍文本。如果它们发现某个短语在某本书中相对于所有《搜索内容!》书籍出现的频率很高,那么该短语就是该书中的 SIP。”

SIPs 在某本书中不一定是不太可能的,但相对于《搜索内容!》中的所有书籍,它们是不太可能的。

前景模型将是正在处理的书籍,而背景模型将是亚马逊《搜索内容!™》项目中的所有其他书籍。虽然亚马逊可能已经引入了一些不同的调整,但基本理念是相同的。

准备工作

有几个数据源值得查看,以便通过两个独立的语言模型得到有趣的短语。关键在于,你希望背景模型作为预期单词/短语分布的来源,帮助突出前景模型中的有趣短语。一些示例包括:

  • 时间分隔的推特数据:时间分隔的推特数据示例如下:

    • 背景模型:这指的是直到昨天关于迪士尼世界的一整年的推文。

    • 前景模型:今天的推文。

    • 有趣的短语:今天关于迪士尼世界在推特上的新内容。

  • 话题分隔的推特数据:话题分隔的推特数据示例如下:

    • 背景模型:关于迪士尼乐园的推文

    • 前景模型:关于迪士尼世界的推文

    • 有趣的短语:关于迪士尼世界说的而不是关于迪士尼乐园说的

  • 相似主题的书籍:关于相似主题的书籍示例如下:

    • 背景模型:一堆早期的科幻小说

    • 前景模型:儒勒·凡尔纳的世界大战

    • 有趣的短语:《世界大战》的独特短语和概念

如何操作……

这是运行一个前景或背景模型来处理关于迪士尼乐园与迪士尼世界推文的步骤:

  1. 在命令行中输入:

    java -cp  lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar:lib/opencsv-2.4.jar com.lingpipe.cookbook.chapter4.InterestingPhrasesForegroundBackground
    
    
  2. 输出将类似于:

    Score 989.621859 : [sleeping, beauty]
    Score 989.621859 : [california, adventure]
    Score 521.568529 : [winter, dreams]
    Score 367.309361 : [disneyland, resort]
    Score 339.429700 : [talking, about]
    Score 256.473825 : [disneyland, during]
    
    
  3. 前景模型包括关于搜索词disneyland的推文,背景模型包括关于搜索词disneyworld的推文。

  4. 排名前列的结果是关于加利福尼亚州迪士尼乐园独特的特征,特别是城堡的名字——睡美人城堡,以及在迪士尼乐园停车场建造的主题公园——加州冒险乐园。

  5. 下一个二元组是关于冬季梦想,它指的是一部电影的首映。

  6. 总体而言,输出效果不错,可以区分这两家度假村的推文。

它是如何工作的……

代码位于src/com/lingpipe/cookbook/chapter4/InterestingPhrasesForegroundBackground.java。当我们加载前景和背景模型的原始.csv数据后,展示内容开始:

TokenizerFactory tokenizerFactory = IndoEuropeanTokenizerFactory.INSTANCE;
tokenizerFactory = new LowerCaseTokenizerFactory(tokenizerFactory);
int minLength = 5;
tokenizerFactory = new LengthFilterTokenizerFactoryPreserveToken(tokenizerFactory, minLength);

人们可以理解为什么我们把第二章,查找和使用单词,完全用来讨论标记化,但是事实证明,大多数 NLP 系统对于字符流如何被拆分成单词或标记非常敏感。在前面的代码片段中,我们看到三个标记化工厂对字符序列进行有效的破坏。前两个在第二章,查找和使用单词中已经得到了充分的介绍,但第三个是一个自定义工厂,需要仔细检查。LengthFilterTokenizerFactoryPreserveToken类的目的在于过滤短标记,同时不丢失相邻信息。目标是处理短语"Disney is my favorite resort",并生成标记(disney, _234, _235, favorite, resort),因为我们不希望在有趣的短语中出现短单词——它们往往能轻易通过简单的统计模型,并破坏输出。有关第三个标记器的源代码,请参见src/come/lingpipe/cookbook/chapter4/LengthFilterTokenizerFactoryPreserveToken.java。此外,请参阅第二章,查找和使用单词以了解更多说明。接下来是背景模型:

int nGramOrder = 3;
TokenizedLM backgroundLanguageModel = new TokenizedLM(tokenizerFactory, nGramOrder);
for (String [] line: backgroundData) {
  backgroundLanguageModel.train(line[Util.TEXT_OFFSET]);
}

这里构建的是用于判断前景模型中短语新颖性的模型。然后,我们将创建并训练前景模型:

TokenizedLM foregroundLanguageModel = new TokenizedLM(tokenizerFactory,nGramOrder);
for (String [] line: foregroundData) {
  foregroundLanguageModel.train(line[Util.TEXT_OFFSET]);
}

接下来,我们将从前景模型中访问newTermSet()方法。参数和phraseSize决定了标记序列的长度;minCount指定要考虑的短语的最小出现次数,maxReturned控制返回多少结果:

int phraseSize = 2;
int minCount = 3;
int maxReturned = 100;
SortedSet<ScoredObject<String[]>> suprisinglyNewPhrases
    = foregroundLanguageModel.newTermSet(phraseSize, minCount, maxReturned,backgroundLanguageModel);
for (ScoredObject<String[]> scoredTokens : suprisinglyNewPhrases) {
    double score = scoredTokens.score();
    String[] tokens = scoredTokens.getObject();
    System.out.printf("Score %f : ", score);
    System.out.println(java.util.Arrays.asList(tokens));
}

上面的for循环按最令人惊讶到最不令人惊讶的短语顺序打印出短语。

这里发生的细节超出了食谱的范围,但 Javadoc 再次引导我们走向启蒙之路。

使用的确切评分是 z-score,如BinomialDistribution.z(double, int, int)中定义的那样,其中成功概率由背景模型中的 n-gram 概率估计定义,成功的次数是该模型中 n-gram 的计数,试验次数是该模型中的总计数。

还有更多……

这个食谱是我们第一次遇到未知标记的地方,如果处理不当,它们可能具有非常不好的属性。很容易理解为什么这对于基于标记的语言模型的最大似然估计来说是个问题,这是一种通过将每个标记的似然性相乘来估计一些未见标记的语言模型的花哨名称。每个似然性是标记在训练中出现的次数除以数据中出现的标记总数。例如,考虑使用来自*《康涅狄格州的亚瑟王》*的数据进行训练:

“这个故事中提到的冷酷的法律和习俗是历史性的,用来说明它们的事件也是历史性的。”

这非常少的训练数据,但足以证明所提的观点。考虑一下我们如何通过语言模型来估计短语“The ungentle inlaws”。在训练数据中,“The”出现一次,共有 24 个单词;我们将给它分配 1/24 的概率。我们也将给“ungentle”分配 1/24 的概率。如果我们在这里停止,可以说“The ungentle”的概率是 1/24 * 1/24。但是,下一个单词是“inlaws”,它在训练数据中不存在。如果这个词元被赋予 0/24 的值,那么整个字符串的可能性将变为 0(1/24 * 1/24 * 0/20)。这意味着每当有一个未见的词元,且其估计值可能为零时,这通常是一个无用的特性。

解决这个问题的标准方法是替代并近似未在训练中看到的数据的值。解决此问题有几种方法:

  • 为未知词元提供一个低但非零的估计。这是一种非常常见的方法。

  • 使用字符语言模型与未知词元。这在类中有相关的规定——请参考 Javadoc。

  • 还有许多其他方法和大量的研究文献。好的搜索词是“back off”和“smoothing”。

隐马尔可夫模型(HMM)——词性

这个配方引入了 LingPipe 的第一个核心语言学功能;它指的是单词的语法类别或词性POS)。文本中的动词、名词、形容词等是什么?

如何操作...

让我们直接进入,回到那些尴尬的中学英语课堂时光,或者是我们相应的经历:

  1. 像往常一样,去你的命令提示符并键入以下内容:

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar: com.lingpipe.cookbook.chapter9.PosTagger 
    
    
  2. 系统将响应一个提示,我们将在其中添加一条豪尔赫·路易斯·博尔赫斯的引用:

    INPUT> Reality is not always probable, or likely.
    
    
  3. 系统将愉快地响应这个引用:

    Reality_nn is_bez not_* always_rb probable_jj ,_, or_cc likely_jj ._. 
    
    

每个词元后附加有一个_和一个词性标签;nn是名词,rb是副词,等等。完整的标签集和标注器语料库的描述可以在en.wikipedia.org/wiki/Brown_Corpus找到。多玩玩这个。词性标注器是 90 年代 NLP 领域最早的突破性机器学习应用之一。你可以期待它的表现精度超过 90%,尽管它在 Twitter 数据上可能会有点问题,因为底层语料库是 1961 年收集的。

它是如何工作的...

适合食谱书的方式是,我们并未透露如何构建词性标注器的基础知识。可以通过 Javadoc、Web 以及研究文献来帮助你理解底层技术——在训练 HMM 的配方中,简要讨论了底层 HMM。这是关于如何使用呈现的 API:

public static void main(String[] args) throws ClassNotFoundException, IOException {
  TokenizerFactory tokFactory = IndoEuropeanTokenizerFactory.INSTANCE;
  String hmmModelPath = args.length > 0 ? args[0] : "models/pos-en-general-brown.HiddenMarkovModel";
  HiddenMarkovModel hmm = (HiddenMarkovModel) AbstractExternalizable.readObject(new File(hmmModelPath));
  HmmDecoder decoder = new HmmDecoder(hmm);
  BufferedReader bufReader = new BufferedReader(new InputStreamReader(System.in));
  while (true) {
    System.out.print("\n\nINPUT> ");
    System.out.flush();
    String input = bufReader.readLine();
    Tokenizer tokenizer = tokFactory.tokenizer(input.toCharArray(),0,input.length());
    String[] tokens = tokenizer.tokenize();
    List<String> tokenList = Arrays.asList(tokens);
    firstBest(tokenList,decoder);
  }
}

代码首先设置 TokenizerFactory,这很有意义,因为我们需要知道哪些词将会得到词性标注。接下来的一行读取了一个之前训练过的词性标注器,作为 HiddenMarkovModel。我们不会深入讨论细节;你只需要知道 HMM 将词标记 n 的词性标记视为先前标注的函数。

由于这些标签在数据中并不是直接观察到的,这使得马尔可夫模型成为隐含的。通常,回看一两个标记。隐马尔可夫模型(HMM)中有许多值得理解的内容。

下一行的 HmmDecoder decoder 将 HMM 包装到代码中,用于标注提供的标记。接下来的标准交互式 while 循环将进入 firstBest(tokenList, decoder) 方法,并且所有有趣的内容都发生在方法的结尾。该方法如下:

static void firstBest(List<String> tokenList, HmmDecoder decoder) {
  Tagging<String> tagging = decoder.tag(tokenList);
    System.out.println("\nFIRST BEST");
    for (int i = 0; i < tagging.size(); ++i){
      System.out.print(tagging.token(i) + "_" + tagging.tag(i) + " ");
    }
  System.out.println();
}

请注意 decoder.tag(tokenList) 调用,它会产生一个 Tagging<String> 标注。Tagging 没有迭代器或有用的标签/标记对封装,因此需要通过递增索引 i 来访问信息。

N-best 单词标注

计算机科学的确定性驱动特性并未体现在语言学的变数上,合理的博士们至少可以同意或不同意,直到乔姆斯基的亲信出现为止。本配方使用了在前一配方中训练的相同 HMM,但为每个单词提供了可能标签的排名列表。

这在什么情况下可能有帮助?想象一个搜索引擎,它不仅搜索单词,还搜索标签——不一定是词性。这个搜索引擎可以索引单词以及最优的 n 个标签,这些标签可以让匹配的标签进入非首选标签。这可以帮助提高召回率。

如何操作...

N-best 分析推动了 NLP 开发者的技术边界。曾经是单一的,现在是一个排名列表,但它是性能提升的下一阶段。让我们开始执行以下步骤:

  1. 把你那本《句法结构》放好,翻过来并键入以下内容:

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar: com.lingpipe.cookbook.chapter4.NbestPosTagger 
    
    
  2. 然后,输入以下内容:

    INPUT> Colorless green ideas sleep furiously.
    
    
  3. 它将输出以下内容:

    N BEST
    #   JointLogProb         Analysis
    0     -91.141  Colorless_jj   green_jj   ideas_nns  sleep_vb   furiously_rb   ._. 
    1     -93.916  Colorless_jj   green_nn   ideas_nns  sleep_vb   furiously_rb   ._. 
    2     -95.494  Colorless_jj   green_jj   ideas_nns  sleep_rb   furiously_rb   ._. 
    3     -96.266  Colorless_jj   green_jj   ideas_nns  sleep_nn   furiously_rb   ._. 
    4     -98.268  Colorless_jj   green_nn   ideas_nns  sleep_rb   furiously_rb   ._.
    
    

输出列表按从最可能到最不可能的顺序列出整个标记序列的估计,基于 HMM 的估计。记住,联合概率是以对数 2 为基数的。为了比较联合概率,将 -93.9 从 -91.1 中减去,差值为 2.8。因此,标注器认为选项 1 的出现几率是选项 0 的 2 ^ 2.8 = 7 倍小。这个差异的来源在于将名词标记为绿色,而不是形容词。

它是如何工作的……

加载模型和命令输入输出的代码与之前的配方相同。不同之处在于获取和显示标注所使用的方法:

static void nBest(List<String> tokenList, HmmDecoder decoder, int maxNBest) {
  System.out.println("\nN BEST");
  System.out.println("#   JointLogProb         Analysis");
  Iterator<ScoredTagging<String>> nBestIt = decoder.tagNBest(tokenList,maxNBest);
  for (int n = 0; nBestIt.hasNext(); ++n) {
    ScoredTagging<String> scoredTagging = nBestIt.next();
    System.out.printf(n + "   %9.3f  ",scoredTagging.score());
    for (int i = 0; i < tokenList.size(); ++i){
      System.out.print(scoredTagging.token(i) + "_" + pad(scoredTagging.tag(i),5));
    }
    System.out.println();
  }

除了在标注迭代过程中解决格式化问题外,没有太多复杂的内容。

基于置信度的标注

另一个视图展示了标注概率,这反映了在单词级别的概率分配。代码反映了底层的TagLattice,并提供了对标注器是否有信心的洞察。

如何实现...

本食谱将把概率估计集中在单个标记上。请执行以下步骤:

  1. 在命令行或 IDE 中键入以下内容:

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar: com.lingpipe.cookbook.chapter4.ConfidenceBasedTagger
    
    
  2. 然后,输入以下内容:

    INPUT> Colorless green ideas sleep furiously.
    
    
  3. 它会生成以下输出:

    CONFIDENCE
    #   Token          (Prob:Tag)*
    0   Colorless           0.991:jj       0.006:np$      0.002:np 
    1   green               0.788:jj       0.208:nn       0.002:nns 
    2   ideas               1.000:nns      0.000:rb       0.000:jj 
    3   sleep               0.821:vb       0.101:rb       0.070:nn 
    4   furiously           1.000:rb       0.000:ql       0.000:jjr 
    5   .                   1.000:.        0.000:np       0.000:nn 
    
    

这种数据视图分配了标签和词的联合概率。我们可以看到green.208的概率应该被标记为nn(名词单数),但正确的分析仍然是.788,标记为形容词jj

它是如何工作的…

我们仍然使用的是隐藏马尔可夫模型(HMM)——词性食谱中的旧 HMM,但使用了不同的部分。读取模型的代码完全相同,主要的区别在于我们报告结果的方式。src/com/lingpipe/cookbook/chapter4/ConfidenceBasedTagger.java中的方法:

static void confidence(List<String> tokenList, HmmDecoder decoder) {
  System.out.println("\nCONFIDENCE");
  System.out.println("#   Token          (Prob:Tag)*");
  TagLattice<String> lattice = decoder.tagMarginal(tokenList);

  for (int tokenIndex = 0; tokenIndex < tokenList.size(); ++tokenIndex) {
    ConditionalClassification tagScores = lattice.tokenClassification(tokenIndex);
    System.out.print(pad(Integer.toString(tokenIndex),4));
    System.out.print(pad(tokenList.get(tokenIndex),15));

    for (int i = 0; i < 3; ++i) {
      double conditionalProb = tagScores.score(i);
      String tag = tagScores.category(i);
      System.out.printf(" %9.3f:" + pad(tag,4),conditionalProb);

    }
    System.out.println();
  }
}

该方法明确演示了标记的底层格子到概率的映射,这就是 HMM 的核心。更改for循环的终止条件,以查看更多或更少的标签。

训练词性标注

当你可以创建自己的模型时,词性标注变得更加有趣。注释词性标注语料库的领域对于一本简单的食谱书来说有些过于复杂——词性数据的注释非常困难,因为它需要相当多的语言学知识才能做得好。本食谱将直接解决基于 HMM 的句子检测器的机器学习部分。

由于这是一本食谱书,我们将简单解释一下什么是 HMM。我们一直在使用的标记语言模型会根据当前估计词汇前面的一些词/标记来进行前文上下文计算。HMM 在计算当前标记的标签估计时,会考虑前面标签的一些长度。这使得看似不同的邻接词,如ofin,变得相似,因为它们都是介词。

句子检测食谱中,来自第五章,文本中的跨度 – 分块,基于HeuristicSentenceModel的句子检测器虽然有用,但灵活性不强。与其修改/扩展HeuristicSentenceModel,我们将基于我们注释的数据构建一个基于机器学习的句子系统。

如何实现...

这里的步骤描述了如何运行src/com/lingpipe/cookbook/chapter4/HMMTrainer.java中的程序:

  1. 可以创建一个新的句子注释数据集,或使用以下默认数据,该数据位于data/connecticut_yankee_EOS.txt。如果你自己处理数据,只需编辑一些文本,并用[]标记句子边界。我们的示例如下:

    [The ungentle laws and customs touched upon in this tale are
    historical, and the episodes which are used to illustrate them
    are also historical.] [It is not pretended that these laws and
    customs existed in England in the sixth century; no, it is only
    pretended that inasmuch as they existed in the English and other
    civilizations of far later times, it is safe to consider that it is
    no libel upon the sixth century to suppose them to have been in
    practice in that day also.] [One is quite justified in inferring
    that whatever one of these laws or customs was lacking in that
    remote time, its place was competently filled by a worse one.]
    
  2. 打开命令提示符并运行以下命令启动程序:

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar com.lingpipe.cookbook.chapter4.HmmTrainer
    
    
  3. 它将输出如下内容:

    Training The/BOS ungentle/WORD laws/WORD and/WORD customs/WORD touched/WORD…
    done training, token count: 123
    Enter text followed by new line
    > The cat in the hat. The dog in a bog.
    The/BOS cat/WORD in/WORD the/WORD hat/WORD ./EOS The/BOS dog/WORD in/WORD a/WORD bog/WORD ./EOS
    
    
  4. 输出是一个标记化文本,包含三种标签之一:BOS表示句子的开始,EOS表示句子的结束,WORD表示所有其他的标记。

它是如何工作的…

与许多基于跨度的标记一样,span注解被转换为标记级别的注解,如配方输出中所示。因此,首先的任务是收集注解文本,设置TokenizerFactory,然后调用一个解析子程序将其添加到List<Tagging<String>>中:

public static void main(String[] args) throws IOException {
  String inputFile = args.length > 0 ? args[0] : "data/connecticut_yankee_EOS.txt";
  char[] text = Files.readCharsFromFile(new File(inputFile), Strings.UTF8);
  TokenizerFactory tokenizerFactory = IndoEuropeanTokenizerFactory.INSTANCE;
  List<Tagging<String>> taggingList = new ArrayList<Tagging<String>>();
  addTagging(tokenizerFactory,taggingList,text);

解析前述格式的子程序首先通过IndoEuropeanTokenizer对文本进行标记化,这个标记化器的优点是将[]作为独立的标记处理。它不检查句子分隔符是否格式正确——一个更健壮的解决方案将需要做这件事。难点在于,我们希望在生成的标记流中忽略这些标记,但又希望使用它来使得[后面的标记为 BOS,而]前面的标记为 EOS。其他标记只是WORD。该子程序构建了一个并行的Lists<String>实例来存储标记和标记词,然后用它创建Tagging<String>并将其添加到taggingList中。第二章中的标记化配方,查找和处理单词,涵盖了标记化器的工作原理。请看下面的代码片段:

static void addTagging(TokenizerFactory tokenizerFactory, List<Tagging<String>> taggingList, char[] text) {
  Tokenizer tokenizer = tokenizerFactory.tokenizer(text, 0, text.length);
  List<String> tokens = new ArrayList<String>();
  List<String> tags = new ArrayList<String>();
  boolean bosFound = false;
  for (String token : tokenizer.tokenize()) {
    if (token.equals("[")) {
      bosFound = true;
    }
    else if (token.equals("]")) {
      tags.set(tags.size() - 1,"EOS");
    }
    else {
      tokens.add(token);
      if (bosFound) {
        tags.add("BOS");
        bosFound = false;
      }
      else {
        tags.add("WORD");
      }
    }
  }
  if (tokens.size() > 0) {
    taggingList.add(new Tagging<String>(tokens,tags));
  }
}

前面的代码有一个微妙之处。训练数据被视为单一的标记——这将模拟当我们在新数据上使用句子检测器时,输入的样子。如果训练中使用了多个文档/章节/段落,那么我们将针对每一块文本调用这个子程序。

返回到main()方法,我们将设置ListCorpus并逐一将标记添加到语料库的训练部分。也有addTest()方法,但本例不涉及评估;如果涉及评估,我们很可能会使用XValidatingCorpus

ListCorpus<Tagging<String>> corpus = new ListCorpus<Tagging<String>> ();
for (Tagging<String> tagging : taggingList) {
  System.out.println("Training " + tagging);
  corpus.addTrain(tagging);
}

接下来,我们将创建HmmCharLmEstimator,这就是我们的 HMM。请注意,有一些构造函数允许定制参数来影响性能——请参见 Javadoc。接下来,估算器将针对语料库进行训练,创建HmmDecoder,它将实际标记标记,如下面的代码片段所示:

HmmCharLmEstimator estimator = new HmmCharLmEstimator();
corpus.visitTrain(estimator);
System.out.println("done training, token count: " + estimator.numTrainingTokens());
HmmDecoder decoder = new HmmDecoder(estimator);

在下面的代码片段中,我们的标准 I/O 循环会被调用以获取一些用户反馈。一旦我们从用户那获得一些文本,它将通过我们用于训练的相同标记器进行标记化,并且解码器将展示生成的标记。

注意,训练分词器不必与生产分词器相同,但必须小心不要以完全不同的方式进行分词;否则,HMM 将无法看到它训练时使用的标记。接着会使用回退模型,这可能会降低性能。看一下以下的代码片段:

BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
while (true) {
  System.out.print("Enter text followed by new line\n>");
  String evalText = reader.readLine();
  Tokenizer tokenizer = tokenizerFactory.tokenizer(evalText.toCharArray(),0,evalText.length());
  List<String> evalTokens = Arrays.asList(tokenizer.tokenize());
  Tagging<String> evalTagging = decoder.tag(evalTokens);
  System.out.println(evalTagging);
}

就是这样!为了真正将其封装成一个合适的句子检测器,我们需要将其映射回原始文本中的字符偏移量,但这部分在第五章,在文本中查找跨度——分块中有讲解。这足以展示如何使用 HMM。一个更完备的方法将确保每个 BOS 都有一个匹配的 EOS,反之亦然。而 HMM 并没有这样的要求。

还有更多……

我们有一个小型且易于使用的词性标注语料库;这使我们能够展示如何将 HMM 的训练应用于一个完全不同的问题,并得出相同的结果。这就像我们的如何分类情感——简单版的食谱,在第一章,简单分类器;语言识别和情感分类之间唯一的区别是训练数据。为了简单起见,我们将从一个硬编码的语料库开始——它位于src/com/lingpipe/cookbook/chapter4/TinyPosCorus.java

public class TinyPosCorpus extends Corpus<ObjectHandler<Tagging<String>>> {

  public void visitTrain(ObjectHandler<Tagging<String>> handler) {
    for (String[][] wordsTags : WORDS_TAGSS) {
      String[] words = wordsTags[0];
      String[] tags = wordsTags[1];
      Tagging<String> tagging = new Tagging<String>(Arrays.asList(words),Arrays.asList(tags));
      handler.handle(tagging);
    }
  }

  public void visitTest(ObjectHandler<Tagging<String>> handler) {
    /* no op */
  }

  static final String[][][] WORDS_TAGSS = new String[][][] {
    { { "John", "ran", "." },{ "PN", "IV", "EOS" } },
    { { "Mary", "ran", "." },{ "PN", "IV", "EOS" } },
    { { "John", "jumped", "!" },{ "PN", "IV", "EOS" } },
    { { "The", "dog", "jumped", "!" },{ "DET", "N", "IV", "EOS" } },
    { { "The", "dog", "sat", "." },{ "DET", "N", "IV", "EOS" } },
    { { "Mary", "sat", "!" },{ "PN", "IV", "EOS" } },
    { { "Mary", "likes", "John", "." },{ "PN", "TV", "PN", "EOS" } },
    { { "The", "dog", "likes", "Mary", "." }, { "DET", "N", "TV", "PN", "EOS" } },
    { { "John", "likes", "the", "dog", "." }, { "PN", "TV", "DET", "N", "EOS" } },
    { { "The", "dog", "ran", "." },{ "DET", "N", "IV", "EOS", } },
    { { "The", "dog", "ran", "." },{ "DET", "N", "IV", "EOS", } }
  };

语料库手动创建了标记和静态WORDS_TAGS中标记的每个词的标签,并为每个句子创建了Tagging<String>;在这种情况下,Tagging<String>由两个对齐的List<String>实例组成。然后,这些标注被发送到Corpus超类的handle()方法。替换这个语料库看起来像这样:

/*
List<Tagging<String>> taggingList = new ArrayList<Tagging<String>>();
addTagging(tokenizerFactory,taggingList,text);
ListCorpus<Tagging<String>> corpus = new ListCorpus<Tagging<String>> ();
for (Tagging<String> tagging : taggingList) {
  System.out.println("Training " + tagging);
  corpus.addTrain(tagging);
}
*/

Corpus<ObjectHandler<Tagging<String>>> corpus = new TinyPosCorpus();
HmmCharLmEstimator estimator = new HmmCharLmEstimator();
corpus.visitTrain(estimator);

我们仅仅注释掉了加载带有句子检测和特征的语料库的代码,并将TinyPosCorpus替换进去。它不需要添加数据,所以我们只需使用它来训练 HMM。为了避免混淆,我们创建了一个单独的类HmmTrainerPos.java。运行它将得到以下结果:

java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar 
done training, token count: 42
Enter text followed by new line
> The cat in the hat is back.
The/DET cat/N in/TV the/DET hat/N is/TV back/PN ./EOS

唯一的错误是in是一个及物动词TV。训练数据非常小,因此错误是可以预期的。就像第一章,简单分类器中语言识别和情感分类的区别一样,通过仅仅改变训练数据,HMM 用来学习一个非常不同的现象。

词标注评估

词标注评估推动了下游技术的发展,比如命名实体识别,而这些技术又推动了如共指消解等高端应用。你会注意到,大部分评估与我们分类器的评估相似,唯一的不同是每个标签都像自己的分类器类别一样被评估。

这个食谱应能帮助你开始进行评估,但请注意,我们网站上有一个关于标注评估的非常好的教程,地址是alias-i.com/lingpipe/demos/tutorial/posTags/read-me.html;这个食谱更详细地介绍了如何最佳地理解标注器的表现。

这个食谱简短且易于使用,因此你没有理由不去评估你的标注器。

准备工作

以下是我们评估器的类源代码,位于src/com/lingpipe/cookbook/chapter4/TagEvaluator.java

public class TagEvaluator {
  public static void main(String[] args) throws ClassNotFoundException, IOException {
    HmmDecoder decoder = null;
    boolean storeTokens = true;
    TaggerEvaluator<String> evaluator = new TaggerEvaluator<String>(decoder,storeTokens);
    Corpus<ObjectHandler<Tagging<String>>> smallCorpus = new TinyPosCorpus();
    int numFolds = 10;
    XValidatingObjectCorpus<Tagging<String>> xValCorpus = new XValidatingObjectCorpus<Tagging<String>>(numFolds);
    smallCorpus.visitCorpus(xValCorpus);
    for (int i = 0; i < numFolds; ++i) {
      xValCorpus.setFold(i);
      HmmCharLmEstimator estimator = new HmmCharLmEstimator();
      xValCorpus.visitTrain(estimator);
      System.out.println("done training " + estimator.numTrainingTokens());
      decoder = new HmmDecoder(estimator);
      evaluator.setTagger(decoder);
      xValCorpus.visitTest(evaluator);
    }
    BaseClassifierEvaluator<String> classifierEval = evaluator.tokenEval();
    System.out.println(classifierEval);
  }
}

如何操作…

我们将指出前面代码中的有趣部分:

  1. 首先,我们将设置TaggerEvaluator,其包含一个空的HmmDecoder和一个控制是否存储标记的booleanHmmDecoder对象将在后续代码的交叉验证代码中设置:

    HmmDecoder decoder = null;
    boolean storeTokens = true;
    TaggerEvaluator<String> evaluator = new TaggerEvaluator<String>(decoder,storeTokens);
    
  2. 接下来,我们将加载前一个食谱中的TinyPosCorpus并使用它填充XValididatingObjectCorpus——这是一种非常巧妙的技巧,允许在语料库类型之间轻松转换。注意,我们选择了 10 折——语料库只有 11 个训练示例,因此我们希望最大化每个折叠中的训练数据量。如果你是这个概念的新手,请查看第一章,简单分类器中的如何进行训练和交叉验证评估食谱。请查看以下代码片段:

    Corpus<ObjectHandler<Tagging<String>>> smallCorpus = new TinyPosCorpus();
    int numFolds = 10;
    XValidatingObjectCorpus<Tagging<String>> xValCorpus = new XValidatingObjectCorpus<Tagging<String>>(numFolds);
    smallCorpus.visitCorpus(xValCorpus);
    
  3. 以下代码片段是一个for()循环,它迭代折叠的数量。循环的前半部分处理训练:

    for (int i = 0; i < numFolds; ++i) {
      xValCorpus.setFold(i);
      HmmCharLmEstimator estimator = new HmmCharLmEstimator();
      xValCorpus.visitTrain(estimator);
      System.out.println("done training " + estimator.numTrainingTokens());
    
  4. 循环的其余部分首先为 HMM 创建解码器,将评估器设置为使用该解码器,然后将适当配置的评估器应用于语料库的测试部分:

    decoder = new HmmDecoder(estimator);
    evaluator.setTagger(decoder);
    xValCorpus.visitTest(evaluator);
    
  5. 最后的几行代码应用于所有折叠的语料库已用于训练和测试后。注意,评估器是BaseClassifierEvaluator!它将每个标签作为一个类别报告:

    BaseClassifierEvaluator<String> classifierEval = evaluator.tokenEval();
    System.out.println(classifierEval);
    
  6. 为评估的洪流做好准备。以下是其中的一小部分,即你应该从第一章,简单分类器中熟悉的混淆矩阵:

    Confusion Matrix
    reference \ response
      ,DET,PN,N,IV,TV,EOS
      DET,4,2,0,0,0,0
      PN,0,7,0,1,0,0
      N,0,0,4,1,1,0
      IV,0,0,0,8,0,0
      TV,0,1,0,0,2,0
      EOS,0,0,0,0,0,11
    

就这样。你有了一个与第一章,简单分类器中的分类器评估密切相关的评估设置。

还有更多…

针对 n 最佳词标注,存在评估类,即NBestTaggerEvaluatorMarginalTaggerEvaluator,用于信心排名。同样,可以查看更详细的词性标注教程,里面有关于评估指标的详细介绍,以及一些示例软件来帮助调整 HMM。

条件随机场(CRF)用于词/标记标注

条件随机场CRF)是第三章的逻辑回归配方的扩展,应用于词标注。在第一章的简单分类器中,我们讨论了将问题编码为分类问题的各种方式。CRF 将序列标注问题视为找到最佳类别,其中每个类别(C)是 C*T 标签(T)分配到词元的其中之一。

例如,如果我们有词组Therain,并且标签d表示限定词,n表示名词,那么 CRF 分类器的类别集如下:

  • 类别 1d d

  • 类别 2n d

  • 类别 3n n

  • 类别 4d d

为了使这个组合计算的噩梦变得可计算,采用了各种优化方法,但这是大致的思路。疯狂,但它有效。

此外,CRF 允许像逻辑回归对分类所做的那样,在训练中使用随机特征。此外,它具有针对上下文优化的 HMM 样式观察的数据结构。它在词性标注中的使用并不令人兴奋,因为我们当前的 HMM 已经接近最先进的技术。CRF 真正有所作为的地方是像命名实体识别这样的使用案例,这些内容在第五章的在文本中寻找跨度 - 分块中有所涵盖,但我们希望在通过分块接口使演示更加复杂之前,先讨论纯 CRF 实现。

alias-i.com/lingpipe/demos/tutorial/crf/read-me.html上有一篇关于 CRF 的详细优秀教程;这个配方与该教程非常接近。你将在那里找到更多的信息和适当的参考文献。

如何做到……

我们到目前为止所展示的所有技术都是在上一个千年发明的;这是一项来自新千年的技术。请按照以下步骤进行操作:

  1. 在命令行中输入:

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar: com.lingpipe.cookbook.chapter4.CRFTagger
    
    
  2. 控制台继续显示收敛结果,这些结果应该与你在第三章的逻辑回归配方中见过的非常相似,我们将得到标准的命令行提示符:

    Enter text followed by new line
    >The rain in Spain falls mainly on the plain.
    
    
  3. 对此,我们将得到一些相当混乱的输出:

    The/DET rain/N in/TV Spain/PN falls/IV mainly/EOS on/DET the/N plain/IV ./EOS
    
    
  4. 这是一个糟糕的输出,但 CRF 已经在 11 个句子上进行了训练。所以,我们不要过于苛刻——特别是考虑到这项技术在词标注和跨度标注方面表现得尤为出色,只要提供足够的训练数据来完成它的工作。

它是如何工作的……

与逻辑回归类似,我们需要执行许多与配置相关的任务,以使这个类能够正常运行。本食谱将处理代码中的 CRF 特定方面,并参考第三章中的逻辑回归食谱,了解与配置相关的逻辑回归部分。

main()方法的顶部开始,我们将获取我们的语料库,这部分在前面三个食谱中有讨论:

Corpus<ObjectHandler<Tagging<String>>> corpus = new TinyPosCorpus();

接下来是特征提取器,它是 CRF 训练器的实际输入。它之所以是最终的,仅仅是因为一个匿名内部类将访问它,以展示在下一个食谱中如何进行特征提取:

final ChainCrfFeatureExtractor<String> featureExtractor
  = new SimpleCrfFeatureExtractor();

我们将在本食谱后面讨论这个类的工作原理。

接下来的配置块是针对底层逻辑回归算法的。有关更多信息,请参考第三章中的逻辑回归食谱,看看以下代码片段:

boolean addIntercept = true;
int minFeatureCount = 1;
boolean cacheFeatures = false;
boolean allowUnseenTransitions = true;
double priorVariance = 4.0;
boolean uninformativeIntercept = true;
RegressionPrior prior = RegressionPrior.gaussian(priorVariance, uninformativeIntercept);
int priorBlockSize = 3;
double initialLearningRate = 0.05;
double learningRateDecay = 0.995;
AnnealingSchedule annealingSchedule = AnnealingSchedule.exponential(initialLearningRate,
  learningRateDecay);
double minImprovement = 0.00001;
int minEpochs = 2;
int maxEpochs = 2000;
Reporter reporter = Reporters.stdOut().setLevel(LogLevel.INFO);

接下来,使用以下内容来训练 CRF:

System.out.println("\nEstimating");
ChainCrf<String> crf = ChainCrf.estimate(corpus,featureExtractor,addIntercept,minFeatureCount,cacheFeatures,allowUnseenTransitions,prior,priorBlockSize,annealingSchedule,minImprovement,minEpochs,maxEpochs,reporter);

其余的代码只是使用标准的 I/O 循环。有关tokenizerFactory如何工作的内容,请参考第二章,查找和使用单词

TokenizerFactory tokenizerFactory = IndoEuropeanTokenizerFactory.INSTANCE;
BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
while (true) {
  System.out.print("Enter text followed by new line\n>");
  System.out.flush();
  String text = reader.readLine();
  Tokenizer tokenizer = tokenizerFactory.tokenizer(text.toCharArray(),0,text.length());
  List<String> evalTokens = Arrays.asList(tokenizer.tokenize());
  Tagging<String> evalTagging = crf.tag(evalTokens);
  System.out.println(evalTagging);

SimpleCrfFeatureExtractor

现在,我们将进入特征提取器部分。提供的实现 closely mimics 标准 HMM 的特性。com/lingpipe/cookbook/chapter4/SimpleCrfFeatureExtractor.java 类以如下内容开始:

public class SimpleCrfFeatureExtractor implements ChainCrfFeatureExtractor<String> {
  public ChainCrfFeatures<String> extract(List<String> tokens, List<String> tags) {
    return new SimpleChainCrfFeatures(tokens,tags);
  }

ChainCrfFeatureExtractor接口要求一个extract()方法,该方法接收令牌和相关的标签,并将它们转换为ChainCrfFeatures<String>,在此案例中是这样的。这个过程由下面的一个内部类SimpleChainCrfFeatures处理;该内部类继承自ChainCrfFeatures,并提供了抽象方法nodeFeatures()edgeFeatures()的实现:

static class SimpleChainCrfFeatures extends ChainCrfFeatures<String> {

以下构造函数访问将令牌和标签传递给超类,超类将进行账务处理,以支持查找tagstokens

public SimpleChainCrfFeatures(List<String> tokens, List<String> tags) {
  super(tokens,tags);
}

节点特征计算如下:

public Map<String,Double> nodeFeatures(int n) {
  ObjectToDoubleMap<String> features = new ObjectToDoubleMap<String>();
  features.increment("TOK_" + token(n),1.0);
  return features;
}

令牌根据它们在句子中的位置进行索引。位置为n的单词/令牌的节点特征是通过ChainCrfFeatures的基类方法token(n)返回的String值,前缀为TOK_。这里的值是1.0。特征值可以有用地调整为 1.0 以外的其他值,这对于更复杂的 CRF 方法非常有用,比如使用其他分类器的置信度估计。看看下面的食谱,以了解如何实现这一点。

与 HMM 类似,有些特征依赖于输入中的其他位置——这些被称为边缘特征。边缘特征接受两个参数:一个是生成特征的位置nk,它将适用于句子中的所有其他位置:

public Map<String,Double> edgeFeatures(int n, int k) {
  ObjectToDoubleMap<String> features = new ObjectToDoubleMap<String>();
  features.increment("TAG_" + tag(k),1.0);
  return features;
}

下一篇食谱将处理如何修改特征提取。

还有更多内容……

Javadoc 中引用了大量研究文献,LingPipe 网站上也有一个更加详细的教程。

修改 CRF

CRF 的强大和吸引力来源于丰富的特征提取——通过提供反馈的评估工具来进行你的探索。本示例将详细介绍如何创建更复杂的特征。

如何操作……

我们不会训练和运行 CRF;相反,我们将打印出特征。将此特征提取器替换为之前示例中的特征提取器,以查看它们的工作效果。执行以下步骤:

  1. 打开命令行并输入:

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar: com.lingpipe.cookbook.chapter4.ModifiedCrfFeatureExtractor
    
    
  2. 特征提取器类会为训练数据中的每个标记输出真实标签,这些标签用于学习:

    -------------------
    Tagging:  John/PN
    
    
  3. 这反映了John标记的训练标签,它是由src/com/lingpipe/cookbook/chapter4/TinyPosCorpus.java文件中确定的。

  4. 节点特征遵循我们 Brown 语料库 HMM 标注器的前三个 POS 标签以及TOK_John特征:

    Node Feats:{nps=2.0251355582754984E-4, np=0.9994337160349874, nn=2.994165140854113E-4, TOK_John=1.0}
    
  5. 接下来,显示句子“John ran”中其他标记的边特征:

    Edge Feats:{TOKEN_SHAPE_LET-CAP=1.0, TAG_PN=1.0}
    Edge Feats:{TAG_IV=1.0, TOKEN_SHAPE_LET-CAP=1.0}
    Edge Feats:{TOKEN_SHAPE_LET-CAP=1.0, TAG_EOS=1.0}
    
  6. 剩余的输出为句子中其余标记的特征,然后是TinyPosCorpus中剩余的句子。

它的工作原理是……

我们的特征提取代码位于src/com/lingpipe/cookbook/chapter4/ModifiedCrfFeatureExtractor.java。我们将从加载语料库、通过特征提取器处理内容并打印出来的main()方法开始:

public static void main(String[] args) throws IOException, ClassNotFoundException {

  Corpus <ObjectHandler<Tagging<String>>> corpus = new TinyPosCorpus();
  final ChainCrfFeatureExtractor<String> featureExtractor = new ModifiedCrfFeatureExtractor();

我们将使用之前示例中的TinyPosCorpus作为我们的语料库,然后从包含类创建特征提取器。引用变量在后面的匿名内部类中需要使用final修饰符。

对于匿名内部类表示歉意,但这是访问语料库中存储内容的最简单方式,原因多种多样,例如复制和打印。在这种情况下,我们只是生成并打印训练数据中找到的特征:

corpus.visitCorpus(new ObjectHandler<Tagging<String>>() {
  @Override
  public void handle(Tagging<String> tagging) {
    ChainCrfFeatures<String> features = featureExtractor.extract(tagging.tokens(), tagging.tags());

语料库包含Tagging对象,而它们又包含一个List<String>的标记和标签。然后,使用这些信息通过应用featureExtractor.extract()方法到标记和标签,创建一个ChainCrfFeatures<String>对象。这将涉及大量计算,如将展示的那样。

接下来,我们将对训练数据进行报告,包含标记和预期标签:

for (int i = 0; i < tagging.size(); ++i) {
  System.out.println("---------");
  System.out.println("Tagging:  " + tagging.token(i) + "/" + tagging.tag(i));

接下来,我们将继续展示将用于通知 CRF 模型,以尝试为节点生成前置标签的特征:

System.out.println("Node Feats:" + features.nodeFeatures(i));

然后,通过以下对源节点i相对位置的迭代来生成边特征:

for (int j = 0; j < tagging.size(); ++j) {
  System.out.println("Edge Feats:" 
        + features.edgeFeatures(i, j));
}

现在我们打印出特征。接下来,我们将介绍如何构建特征提取器。假设你已经熟悉之前的示例。首先,构造函数引入了 Brown 语料库 POS 标注器:

HmmDecoder mDecoder;

public ModifiedCrfFeatureExtractor() throws IOException, ClassNotFoundException {
  File hmmFile = new File("models/pos-en-general-" + "brown.HiddenMarkovModel");
  HiddenMarkovModel hmm = (HiddenMarkovModel)AbstractExternalizable.readObject(hmmFile);
  mDecoder = new HmmDecoder(hmm);
}

该构造函数引入了一些外部资源用于特征生成,即一个基于布朗语料库训练的 POS 标注器。为什么要为 POS 标注器引入另一个 POS 标注器呢?我们将布朗 POS 标注器的角色称为“特征标注器”,以将其与我们正在构建的标注器区分开来。使用特征标注器的原因有几个:

  • 我们使用的是一个非常小的语料库进行训练,一个更强大的通用 POS 特征标注器将帮助改善结果。TinyPosCorpus语料库甚至太小,无法带来这样的好处,但如果有更多的数据,at这个特征统一了theasome,这将帮助 CRF 识别出some dog应该是'DET' 'N',即便在训练中它从未见过some

  • 我们不得不与那些与 POS 特征标注器不一致的标签集一起工作。CRF 可以使用这些外部标签集中的观察结果来更好地推理期望的标注。最简单的情况是,来自布朗语料库标签集中的at可以干净地映射到当前标签集中的DET

  • 可以通过运行多个标注器来提高性能,这些标注器可以基于不同的数据进行训练,或使用不同的技术进行标注。然后,CRF 可以在希望的情况下识别出一个标注器优于其他标注器的上下文,并利用这些信息来引导分析。在过去,我们的 MUC-6 系统使用了 3 个 POS 标注器,它们投票选出最佳输出。让 CRF 来解决这个问题会是一种更优的方法。

特征提取的核心通过extract方法访问:

public ChainCrfFeatures<String> extract(List<String> tokens, List<String> tags) {
  return new ModChainCrfFeatures(tokens,tags);
}

ModChainCrfFeatures作为一个内部类创建,旨在将类的数量保持在最低限度,且外部类非常轻量:

class ModChainCrfFeatures extends ChainCrfFeatures<String> {

  TagLattice<String> mBrownTaggingLattice;

  public ModChainCrfFeatures(List<String> tokens, List<String> tags) {
    super(tokens,tags);
    mBrownTaggingLattice = mDecoder.tagMarginal(tokens);
  }

上述构造函数将令牌和标签交给父类,父类负责处理这些数据的记账工作。然后,“特征标注器”应用于令牌,结果输出被分配给成员变量mBrownTaggingLattice。代码将一次访问一个令牌的标注,因此现在必须计算这些标注。

特征创建步骤通过两个方法进行:nodeFeaturesedgeFeatures。我们将从对前一个配方中edgeFeatures的简单增强开始:

public Map<String,? extends Number> edgeFeatures(int n, int k) {
  ObjectToDoubleMap<String> features = newObjectToDoubleMap<String>();
  features.set("TAG_" + tag(k), 1.0d);
  String category = IndoEuropeanTokenCategorizer.CATEGORIZER.categorize(token(n));
  features.set("TOKEN_SHAPE_" + category,1.0d);
  return features;
}

代码添加了一个令牌形态特征,将1234泛化为2-DIG以及其他许多泛化。对于 CRF 而言,除非特征提取另有说明,否则1234作为两位数之间的相似性是不存在的。请参阅 Javadoc 获取完整的分类器输出。

候选边缘特征

CRF 允许应用随机特征,因此问题是哪些特征是有意义的。边缘特征与节点特征一起使用,因此另一个问题是特征应该应用于边缘还是节点。边缘特征将用于推理当前词/令牌与周围词语的关系。一些可能的边缘特征包括:

  • 前一个令牌的形态(全大写、以数字开头等),如前所述。

  • 需要正确排序重音和非重音音节的抑扬格五音步识别。这还需要一个音节重音分词器。

  • 文本中经常包含一种或多种语言——这叫做代码切换。这在推文中是常见的现象。一个合理的边缘特征将是周围令牌的语言;这种语言可以更好地建模下一词可能与前一词属于同一语言。

节点特征

节点特征通常是 CRF 中动作的关键所在,并且它们可以变得非常丰富。在第五章中的使用 CRF 和更好的特征进行命名实体识别方法,Finding Spans in Text – Chunking,就是一个例子。在这个方法中,我们将为前一个方法的令牌特征添加词性标注:

public Map<String,? extends Number> nodeFeatures(int n) {
  ObjectToDoubleMap<String> features = new ObjectToDoubleMap<String>();
  features.set("TOK_" + token(n), 1);
  ConditionalClassification tagScores = mBrownTaggingLattice.tokenClassification(n);
  for (int i = 0; i < 3; ++ i) {
    double conditionalProb = tagScores.score(i);
    String tag = tagScores.category(i);
    features.increment(tag, conditionalProb);
  }
  return features;
}

然后,像在前一个方法中一样,通过以下方式添加令牌特征:

features.set("TOK_" + token(n), 1); 

这导致令牌字符串前面加上TOK_和计数1。请注意,虽然tag(n)在训练中可用,但使用该信息没有意义,因为 CRF 的目标就是预测这些标签。

接下来,从词性特征标注器中提取出前三个标签,并与相关的条件概率一起添加。CRF 将能够通过这些变化的权重进行有效的工作。

还有更多…

在生成新特征时,值得考虑数据的稀疏性。如果日期可能是 CRF 的重要特征,可能不适合做计算机科学中的标准操作——将日期转换为自 1970 年 1 月 1 日格林威治标准时间以来的毫秒数。原因是MILLI_1000000000特征将被视为与MILLI_1000000001完全不同。原因有几个:

  • 底层分类器并不知道这两个值几乎相同。

  • 分类器并不知道MILLI_前缀是相同的——这个通用前缀仅仅是为了方便人类。

  • 该特征在训练中不太可能出现多次,可能会被最小特征计数修剪掉。

而不是将日期标准化为毫秒,考虑使用一个抽象层来表示日期,这个日期在训练数据中可能有很多实例,例如忽略实际日期但记录日期存在性的has_date特征。如果日期很重要,那么计算关于日期的所有重要信息。如果它是星期几,那么映射到星期几。如果时间顺序很重要,那么映射到更粗略的度量,这些度量可能有许多测量值。一般来说,CRF 和底层的逻辑回归分类器对于无效特征具有鲁棒性,因此可以大胆尝试创新——添加特征不太可能使准确度更差。

第五章:在文本中查找跨度—分块

本章涵盖以下内容:

  • 句子检测

  • 句子检测的评估

  • 调整句子检测

  • 在字符串中标记嵌套的分块—句子分块示例

  • 段落检测

  • 简单的名词短语和动词短语

  • 基于正则表达式的命名实体识别(NER)分块

  • 基于词典的 NER 分块

  • 在单词标注和分块之间转换—BIO 编解码器

  • 基于隐马尔可夫模型(HMM)的 NER

  • 混合 NER 数据源

  • 用于分块的条件随机场(CRFs)

  • 使用更好的特征的条件随机场(CRFs)进行 NER

介绍

本章将告诉我们如何处理通常涵盖一个或多个单词/标记的文本跨度。LingPipe API 将这种文本单元表示为分块,并使用相应的分块器生成分块。以下是一些带有字符偏移的文本:

LingPipe is an API. It is written in Java.
012345678901234567890123456789012345678901
          1         2         3         4           

将前面的文本分块成句子将会得到如下输出:

Sentence start=0, end=18
Sentence start =20, end=41

为命名实体添加分块,增加了 LingPipe 和 Java 的实体:

Organization start=0, end=7
Organization start=37, end=40

我们可以根据命名实体的偏移量来定义命名实体分块;这对 LingPipe 没有影响,但对 Java 而言会有所不同:

Organization start=17, end=20

这是分块的基本思路。有很多方法可以构建它们。

句子检测

书面文本中的句子大致对应于口头表达。它们是工业应用中处理单词的标准单元。在几乎所有成熟的 NLP 应用程序中,即使是推文(可能在限定的 140 字符内有多个句子),句子检测也是处理管道的一部分。

如何做到这一点...

  1. 和往常一样,我们首先将玩一些数据。请在控制台输入以下命令:

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar: com.lingpipe.cookbook.chapter5.SentenceDetection
    
    
  2. 程序将为您的句子检测实验提供提示。按下回车/换行键结束待分析的文本:

    Enter text followed by new line
    >A sentence. Another sentence.
    SENTENCE 1:
    A sentence.
    SENTENCE 2:
    Another sentence.
    
    
  3. 值得尝试不同的输入。以下是一些示例,用于探索句子检测器的特性。去掉句子开头的首字母大写;这样就能防止检测到第二个句子:

    >A sentence. another sentence.
    SENTENCE 1:
    A sentence. another sentence.
    
    
  4. 检测器不需要结束句号—这是可配置的:

    >A sentence. Another sentence without a final period
    SENTENCE 1:A sentence.
    SENTENCE 2:Another sentence without a final period
    
    
  5. 检测器平衡括号,这样就不会让句子在括号内断开—这也是可配置的:

    >(A sentence. Another sentence.)
    SENTENCE 1: (A sentence. Another sentence.)
    
    

它是如何工作的...

这个句子检测器是基于启发式或规则的句子检测器。统计句子检测器也是一个合理的方案。我们将遍历整个源代码来运行检测器,稍后我们会讨论修改:

package com.lingpipe.cookbook.chapter5;

import com.aliasi.chunk.Chunk;
import com.aliasi.chunk.Chunker;
import com.aliasi.chunk.Chunking;
import com.aliasi.sentences.IndoEuropeanSentenceModel;
import com.aliasi.sentences.SentenceChunker;
import com.aliasi.sentences.SentenceModel;
import com.aliasi.tokenizer.IndoEuropeanTokenizerFactory;
import com.aliasi.tokenizer.TokenizerFactory;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.Set;

public class SentenceDetection {

public static void main(String[] args) throws IOException {
  boolean endSent = true;
  boolean parenS = true;
  SentenceModel sentenceModel = new IndoEuropeanSentenceModel(endSent,parenS);

main类的顶部开始,布尔类型的endSent参数控制是否假定被检测的句子字符串以句子结尾,无论如何—这意味着最后一个字符始终是句子边界—它不一定是句号或其他典型的句子结束符号。改变它,试试没有结束句号的句子,结果将是没有检测到句子。

接下来的布尔值parenS声明在寻找句子时优先考虑括号,而不是句子标记符。接下来,实际的句子分块器将被设置:

TokenizerFactory tokFactory = IndoEuropeanTokenizerFactory.INSTANCE;
Chunker sentenceChunker = new SentenceChunker(tokFactory,sentenceModel);

tokFactory应该对你来说并不陌生,来自第二章,查找和处理单词。然后可以构建sentenceChunker。以下是标准的命令行交互输入/输出代码:

BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
while (true) {
  System.out.print("Enter text followed by new line\n>");
  String text = reader.readLine();

一旦我们得到了文本,句子检测器就会被应用:

Chunking chunking = sentenceChunker.chunk(text);
Set<Chunk> sentences = chunking.chunkSet();

这个分块操作提供了一个Set<Chunk>参数,它非正式地提供了Chunks的适当排序;它们将根据ChunkingImpl的 Javadoc 进行添加。真正偏执的程序员可能会强制执行正确的排序,我们将在本章后面讨论如何处理重叠的分块。

接下来,我们将检查是否找到了任何句子,如果没有找到,我们将向控制台报告:

if (sentences.size() < 1) {
  System.out.println("No sentence chunks found.");
  return;
}

以下是书中首次介绍Chunker接口,并且有一些评论需要说明。Chunker接口生成Chunk对象,这些对象是通过CharSequence(通常是String)上的连续字符序列,带有类型和得分的。Chunks可以重叠。Chunk对象被存储在Chunking中:

String textStored = chunking.charSequence().toString();
for (Chunk sentence : sentences) {
  int start = sentence.start();
  int end = sentence.end();
  System.out.println("SENTENCE :" 
    + textStored.substring(start,end));
  }
}

首先,我们恢复了基础文本字符串textStored,它是Chunks的基础。它与text相同,但我们希望说明Chunking类中这个可能有用的方法,这个方法在递归或其他上下文中可能会出现,其中CharSequence可能不可用。

剩余的for循环遍历句子并使用Stringsubstring()方法将其打印出来。

还有更多...

在讲解如何创建自己的句子检测器之前,值得一提的是 LingPipe 有一个MedlineSentenceModel,它专门处理医学研究文献中常见的句子类型。它已经处理了大量数据,应该是你在这类数据上进行句子检测的起点。

嵌套句子

特别是在文学作品中,句子可能包含嵌套的句子。考虑以下内容:

John said "this is a nested sentence" and then shut up.

前述句子将被正确标注为:

[John said "[this is a nested sentence]" and then shut up.]

这种嵌套与语言学中嵌套句子的概念不同,后者是基于语法角色的。考虑以下例子:

[[John ate the gorilla] and [Mary ate the burger]].

这个句子由两个在语言学上完整的句子通过and连接而成。两者的区别在于前者是由标点符号决定的,后者则由语法功能决定。这个区别是否重要可以讨论。然而,前者的情况在编程中更容易识别。

然而,在工业环境中我们很少需要建模嵌套句子,但在我们的 MUC-6 系统和各种共指解析研究系统中,我们已经涉及过此问题。这超出了食谱书的范围,但请注意这个问题。LingPipe 没有开箱即用的嵌套句子检测功能。

句子检测的评估

就像我们做的大多数事情一样,我们希望能够评估组件的性能。句子检测也不例外。句子检测是一种跨度注释,区别于我们之前对分类器和分词的评估。由于文本中可能有不属于任何句子的字符,因此存在句子开始和句子结束的概念。一个不属于句子的字符示例是来自 HTML 页面的 JavaScript。

以下示例将引导你完成创建评估数据并通过评估类运行它的步骤。

如何操作...

执行以下步骤来评估句子检测:

  1. 打开文本编辑器,复制并粘贴一些你想用来评估句子检测的文学作品,或者你可以使用我们提供的默认文本,如果没有提供自己的数据,则会使用此文本。最简单的方法是使用纯文本。

  2. 插入平衡的[]来标识文本中句子的开始和结束。如果文本中已经包含[],请选择文本中没有的其他字符作为句子分隔符——大括号或斜杠是不错的选择。如果使用不同的分隔符,您需要相应地修改源代码并重新创建 JAR 文件。代码假设使用单字符文本分隔符。以下是来自《银河系漫游指南》的句子注释文本示例——注意并非每个字符都在句子中;一些空格位于句子之间:

    [The Guide says that the best drink in existence is the Pan Galactic Gargle Blaster.] [It says that the effect of a Pan Galactic Gargle Blaster is like having your brains smashed out by a slice of lemon wrapped round a large gold brick.]
    
  3. 打开命令行并运行以下命令:

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar: com.lingpipe.cookbook.chapter5.EvaluateAnnotatedSentences
    TruePos: 0-83/The Guide says that the best drink in existence is the Pan Galactic Gargle Blaster.:S
    TruePos: 84-233/It says that the effect of a Pan Galactic Gargle Blaster is like having your brains smashed out by a slice of lemon wrapped round a large gold brick.:S
    
    
  4. 对于这些数据,代码将显示两个完美匹配的句子,这些句子与用[]注释的句子一致,正如TruePos标签所示。

  5. 一个好的练习是稍微修改注释以强制产生错误。我们将第一个句子边界向前移动一个字符:

    T[he Guide says that the best drink in existence is the Pan Galactic Gargle Blaster.] [It says that the effect of a Pan Galactic Gargle Blaster is like having your brains smashed out by a slice of lemon wrapped round a large gold brick.]
    
    
  6. 保存并重新运行修改后的注释文件后,结果如下:

    TruePos: 84-233/It says that the effect of a Pan Galactic Gargle Blaster is like having your brains smashed out by a slice of lemon wrapped round a large gold brick.:S
    FalsePos: 0-83/The Guide says that the best drink in existence is the Pan Galactic Gargle Blaster.:S
    FalseNeg: 1-83/he Guide says that the best drink in existence is the Pan Galactic Gargle Blaster.:S
    
    

    通过改变真值注释,会产生一个假阴性,因为句子跨度错过了一个字符。此外,由于句子检测器识别了 0-83 字符序列,产生了一个假阳性。

  7. 通过与注释和各种数据的交互,了解评估的工作原理以及句子检测器的能力是一个好主意。

工作原理...

该类从消化注释文本并将句子块存储到评估对象开始。然后,创建句子检测器,就像我们在前面的示例中所做的那样。代码最后通过将创建的句子检测器应用于文本,并打印结果。

解析注释数据

给定带有[]注释的文本表示句子边界,这意味着必须恢复句子的正确偏移量,并且必须创建原始的未注释文本,即没有任何[]。跨度解析器编写起来可能有些棘手,以下代码为了简化而不是为了效率或正确的编程技巧:

String path = args.length > 0 ? args[0] 
             : "data/hitchHikersGuide.sentDetected";
char[] chars 
  = Files.readCharsFromFile(new File(path), Strings.UTF8);
StringBuilder rawChars = new StringBuilder();
int start = -1;
int end = 0;
Set<Chunk> sentChunks = new HashSet<Chunk>();

前面的代码将整个文件作为一个char[]数组读取,并使用适当的字符编码。此外,注意对于大文件,使用流式处理方法会更加节省内存。接下来,设置了一个未注释字符的累加器StringBuilder对象,并通过rawChars变量进行存储。所有遇到的不是[]的字符都将被附加到该对象中。剩余的代码设置了用于句子开始和结束的计数器,这些计数器被索引到未注释的字符数组中,并设置了一个用于注释句子片段的Set<Chunk>累加器。

以下的for循环逐个字符遍历注释过的字符序列:

for (int i=0; i < chars.length; ++i) {
  if (chars[i] == '[') {
    start = rawChars.length();
  }
  else if (chars[i] == ']') {
    end = rawChars.length();

    Chunk chunk = ChunkFactory.createChunk(start,end, SentenceChunker.SENTENCE_CHUNK_TYPE);
    sentChunks.add(chunk);}
  else {
    rawChars.append(chars[i]);
  }
}
String originalText = rawChars.toString();

第一个if (chars[i] == '[')用于测试注释中句子的开始,并将start变量设置为rawChars的长度。迭代变量i包括由注释添加的长度。相应的else if (chars[i] == ']')语句处理句子结束的情况。请注意,这个解析器没有错误检查——这是一个非常糟糕的设计,因为如果使用文本编辑器输入,注释错误非常可能发生。然而,这样做是为了保持代码尽可能简洁。在接下来的章节中,我们将提供一个带有最小错误检查的示例。一旦找到句子的结束,就会使用ChunkFactory.createChunk根据偏移量为句子创建一个分块,并且使用标准的 LingPipe 句子类型SentenceChunker.SENTENCE_CHUNK_TYPE,这是接下来评估类正确工作的必需条件。

剩下的else语句适用于所有非句子边界的字符,它仅仅将字符添加到rawChars累加器中。for循环外部创建String unannotatedText时,可以看到这个累加器的结果。现在,我们已经将句子分块正确地索引到文本字符串中。接下来,我们将创建一个合适的Chunking对象:

ChunkingImpl sentChunking = new ChunkingImpl(unannotatedText);
for (Chunk chunk : sentChunks) {
  sentChunking.add(chunk);
}

实现类ChunkingImplChunking是接口)在构造时需要底层文本,这就是为什么我们没有在前面的循环中直接填充它。LingPipe 通常会尝试使对象构造完整。如果可以不使用底层CharSequence方法创建Chunking,那么调用charSequence()方法时会返回什么呢?空字符串会误导用户。或者,返回null需要捕获并处理。最好直接强制对象构造以确保其合理性。

接下来,我们将看到上一节中句子分块器的标准配置:

boolean eosIsSentBoundary = false;
boolean balanceParens = true;
SentenceModel sentenceModel = new IndoEuropeanSentenceModel(eosIsSentBoundary, balanceParens);
TokenizerFactory tokFactory = IndoEuropeanTokenizerFactory.INSTANCE;
SentenceChunker sentenceChunker = new SentenceChunker(tokFactory,sentenceModel);

有趣的部分紧随其后,评估器将sentenceChunker作为待评估的参数:

SentenceEvaluator evaluator = new SentenceEvaluator(sentenceChunker);

接下来,handle(sentChunking)方法将把我们刚刚解析的文本转化为Chunking,并在sentChunking中提供的CharSequence上运行句子检测器,并设置评估:

evaluator.handle(sentChunking);

然后,我们只需要获取评估数据,并通过对比正确的句子检测与系统执行的结果,逐步分析差异:

SentenceEvaluation eval = evaluator.evaluation();
ChunkingEvaluation chunkEval = eval.chunkingEvaluation();
for (ChunkAndCharSeq truePos : chunkEval.truePositiveSet()) {
  System.out.println("TruePos: " + truePos);
}
for (ChunkAndCharSeq falsePos : chunkEval.falsePositiveSet()) {
  System.out.println("FalsePos: " + falsePos);
}
for (ChunkAndCharSeq falseNeg : chunkEval.falseNegativeSet()){
  System.out.println("FalseNeg: " + falseNeg);
}

这个食谱并没有涵盖所有评估方法——可以查看 Javadoc——但它确实提供了句子检测调整器可能最需要的内容;这列出了句子检测器正确识别的内容(真阳性)、检测到但错误的句子(假阳性)以及漏掉的句子(假阴性)。注意,在跨度注解中,真阴性没有太大意义,因为它们将是所有可能的跨度集合,但不包含在正确的句子检测中。

调整句子检测

很多数据将抵抗IndoEuropeanSentenceModel的魅力,所以这个食谱将为修改句子检测以适应新类型的句子提供一个起点。不幸的是,这是一个非常开放的问题,所以我们将专注于技术,而不是句子格式的可能性。

如何做……

这个食谱将遵循一个常见的模式:创建评估数据、设置评估并开始动手。我们开始吧:

  1. 拿出你最喜欢的文本编辑器并标记一些数据——我们将使用[]标记法。以下是一个违反我们标准IndoEuropeanSentenceModel的示例:

    [All decent people live beyond their incomes nowadays, and those who aren't respectable live beyond other people's.]  [A few gifted individuals manage to do both.]
    
    
  2. 我们将把前面的句子放入data/saki.sentDetected.txt并运行它:

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar: com.lingpipe.cookbook.chapter5.EvaluateAnnotatedSentences data/saki.sentDetected 
    FalsePos: 0-159/All decent people live beyond their incomes nowadays, and those who aren't respectable live beyond other people's.  A few gifted individuals manage to do both.:S
    FalseNeg: 0-114/All decent people live beyond their incomes nowadays, and those who aren't respectable live beyond other people's.:S
    FalseNeg: 116-159/A few gifted individuals manage to do both.:S
    
    

还有更多……

唯一的假阳性对应的是检测到的一个句子,两个假阴性是我们这里注释的两个未被检测到的句子。发生了什么?句子模型漏掉了people's.作为句子结尾。如果删除撇号,句子就能正确检测到——发生了什么?

首先,我们来看一下后台运行的代码。IndoEuropeanSentenceModel通过配置来自HeuristicSentenceModel的 Javadoc 中的几类标记来扩展HeuristicSentenceModel

  • 可能的停止符:这些是可以作为句子结尾的标记。这个集合通常包括句尾标点符号标记,比如句号(.)和双引号(")。

  • 不可能的倒数第二个:这些是可能不是句子的倒数第二个(倒数第二)标记。这个集合通常由缩写或首字母缩写组成,例如Mr

  • 不可能的开头:这些是可能不是句子开头的标记。这个集合通常包括应该与前一句连接的标点符号字符,如结束引号('')。

IndoEuropeanSentenceModel不可配置,但从 Javadoc 中可以看出,所有单个字符都被视为不可能的倒数第二个字符。单词people's被分词为people's.。单个字符s位于.的倒数第二位,因此会被阻止。如何修复这个问题?

有几种选择呈现出来:

  • 忽略这个错误,假设它不会频繁发生

  • 通过创建自定义句子模型来修复

  • 通过修改分词器以避免拆分撇号来修复

  • 为该接口编写一个完整的句子检测模型

第二种选择,创建一个自定义句子模型,通过将IndoEuropeanSentenceModel的源代码复制到一个新类中并进行修改来处理,这是最简单的做法,因为相关的数据结构是私有的。这样做是为了简化类的序列化——几乎不需要将任何配置写入磁盘。在示例类中,有一个MySentenceModel.java文件,它通过明显的包名和导入语句的变化来区分:

IMPOSSIBLE_PENULTIMATES.add("R");
//IMPOSSIBLE_PENULTIMATES.add("S"); breaks on "people's."
//IMPOSSIBLE_PENULTIMATES.add("T"); breaks on "didn't."
IMPOSSIBLE_PENULTIMATES.add("U");

前面的代码只是注释掉了两种可能的单字母倒数第二个标记的情况,这些情况是单个字符的单词。要查看其效果,请将句子模型更改为SentenceModel sentenceModel = new MySentenceModel();,并在EvaluateAnnotatedSentences.java类中重新编译并运行。

如果你将前面的代码视为一个合理的平衡,它可以找到以可能的缩写结尾的句子与非句子情况之间的平衡,例如[Hunter S. Thompson is a famous fellow.],它会将S.识别为句子边界。

扩展HeuristicSentenceModel对于多种类型的数据非常有效。Mitzi Morris 构建了MedlineSentenceModel.java,它设计得很好,适用于 MEDLINE 研究索引中提供的摘要。

看待前面问题的一种方式是,缩写不应被拆分为标记用于句子检测。IndoEuropeanTokenizerFactory应该进行调整,以将"people's"和其他缩写保持在一起。虽然这初看起来似乎稍微比第一个解决方案好,但它可能会遇到IndoEuropeanSentenceModel是针对特定的分词方式进行调整的问题,而在没有评估语料库的情况下,改变的后果是未知的。

另一种选择是编写一个完全新的句子检测类,支持SentenceModel接口。面对像 Twitter 流这样的高度新颖的数据集,我们可以考虑使用基于机器学习的跨度注释技术,如 HMMs 或 CRFs,这些内容在第四章,标注词汇和标记中以及本章末尾讨论过。

标记字符串中的嵌入块——句子块示例

先前食谱中展示块的方法不适用于需要修改底层字符串的应用程序。例如,一个情感分析器可能只想突出显示那些情感强烈的正面句子,而不标记其余句子,同时仍然显示整个文本。在生成标记化文本时的一个小难点是,添加标记会改变底层字符串。这个食谱提供了通过逆序添加块来插入块的工作代码。

如何实现...

尽管这个食谱在技术上可能不复杂,但它对于在文本中添加跨度注释非常有用,而无需从零开始编写代码。src/com/lingpipe/coobook/chapter5/WriteSentDetectedChunks类中包含了参考代码:

  1. 句子块是根据第一个句子检测食谱创建的。以下代码提取块作为Set<Chunk>,然后按照Chunk.LONGEST_MATCH_ORDER_COMPARITOR进行排序。在 Javadoc 中,该比较器被定义为:

    根据文本位置比较两个块。如果一个块比另一个块晚开始,或者它们在相同位置开始但结束得更早,那么前者更大。

    还有TEXT_ORDER_COMPARITOR,如下所示:

    String textStored = chunking.charSequence().toString();
    Set<Chunk> chunkSet = chunking.chunkSet();
    System.out.println("size: " + chunkSet.size());
    Chunk[] chunkArray = chunkSet.toArray(new Chunk[0]);
    Arrays.sort(chunkArray,Chunk.LONGEST_MATCH_ORDER_COMPARATOR);
    
  2. 接下来,我们将按逆序遍历块,这样可以避免为StringBuilder对象的变化长度保持偏移量变量。偏移量变量是一个常见的错误来源,因此这个食谱尽可能避免使用它们,但使用了非标准的逆序循环迭代,这可能更糟:

    StringBuilder output = new StringBuilder(textStored);
    int sentBoundOffset = 0;
    for (int i = chunkArray.length -1; i >= 0; --i) {
      Chunk chunk = chunkArray[i];
      String sentence = textStored.substring(chunk.start(), chunk.end());
      if (sentence.contains("like")) {
        output.insert(chunk.end(),"}");
        output.insert(chunk.start(),"{");
      }
    }
    System.out.println(output.toString());
    
  3. 前面的代码通过查找字符串like来进行非常简单的情感分析,如果找到则标记该句子为true。请注意,这段代码无法处理重叠的块或嵌套的块。它假设一个单一的、不重叠的块集合。一些示例输出如下:

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar: com.lingpipe.cookbook.chapter5.WriteSentDetectedChunks
    Enter text followed by new line
    >People like to ski. But sometimes it is terrifying. 
    size: 2
    {People like to ski.} But sometimes it is terrifying. 
    
    
  4. 要打印嵌套的块,请查看下面的段落 检测食谱。

段落检测

一组句子的典型包含结构是段落。它可以在标记语言中显式设置,例如 HTML 中的<p>,或者通过两个或更多的换行符来设置,这也是段落通常如何呈现的方式。我们处于自然语言处理的领域,这里没有硬性规定,所以我们为这种含糊其辞表示歉意。我们将在本章中处理一些常见的示例,并将推广的部分留给你来完成。

如何实现...

我们从未为段落检测设置过评估工具,但它可以通过类似句子检测的方式进行实现。这个食谱将演示一个简单的段落检测程序,它做了一件非常重要的事情——在进行嵌入句子检测的同时,保持原始文档的偏移量。细节上的关注会在你需要以对句子或文档的其他子跨度(例如命名实体)敏感的方式标记文档时帮助你。请考虑以下示例:

Sentence 1\. Sentence 2
Sentence 3\. Sentence 4.

它被转化为以下内容:

{[Sentence 1.] [Sentence 2]}

{[Sentence 3.] [Sentence 4.]
}

在前面的代码片段中,[] 表示句子,{} 表示段落。我们将直接跳入这个配方的代码,位于 src/com/lingpipe/cookbook/chapter5/ParagraphSentenceDetection.java

  1. 示例代码在段落检测技术方面几乎没有提供什么。它是一个开放性问题,你必须运用你的聪明才智来解决它。我们的段落检测器是一个可悲的 split("\n\n"),在更复杂的方法中,它会考虑上下文、字符和其他特征,这些特征过于独特,无法一一涵盖。以下是读取整个文档作为字符串并将其拆分为数组的代码开头。请注意,paraSeperatorLength 是用于段落拆分的字符数——如果拆分长度有所变化,那么该长度将必须与对应段落相关联:

    public static void main(String[] args) throws IOException {
      String document = Files.readFromFile(new File(args[0]), Strings.UTF8);
      String[] paragraphs = document.split("\n\n");
      int paraSeparatorLength = 2;
    
  2. 该配方的真正目的是帮助维护原始文档中字符偏移量的机制,并展示嵌入式处理。这将通过保持两个独立的块进行:一个用于段落,另一个用于句子:

    ChunkingImpl paraChunking = new ChunkingImpl(document.toCharArray(),0,document.length());
    ChunkingImpl sentChunking = new ChunkingImpl(paraChunking.charSequence());
    
  3. 接下来,句子检测器将以与上一配方中相同的方式进行设置:

    boolean eosIsSentBoundary = true;
    boolean balanceParens = false;
    SentenceModel sentenceModel = new IndoEuropeanSentenceModel(eosIsSentBoundary, balanceParens);
    SentenceChunker sentenceChunker = new SentenceChunker(IndoEuropeanTokenizerFactory.INSTANCE, sentenceModel);
    
  4. 块处理会遍历段落数组,并为每个段落构建一个句子块。这个方法中稍显复杂的部分是,句子块的偏移量是相对于段落字符串的,而不是整个文档。因此,变量的开始和结束在代码中会通过文档偏移量进行更新。块本身没有调整开始和结束的方式,因此必须创建一个新的块 adjustedSentChunk,并将适当的偏移量应用到段落的开始,并将其添加到 sentChunking 中:

    int paraStart = 0;
    for (String paragraph : paragraphs) {
      for (Chunk sentChunk : sentenceChunker.chunk(paragraph).chunkSet()) {
        Chunk adjustedSentChunk = ChunkFactory.createChunk(sentChunk.start() + paraStart,sentChunk.end() + paraStart, "S");
        sentChunking.add(adjustedSentChunk);
      }
    
  5. 循环的其余部分添加段落块,然后用段落的长度加上段落分隔符的长度更新段落的起始位置。这将完成将正确偏移的句子和段落插入到原始文档字符串中的过程:

    paraChunking.add(ChunkFactory.createChunk(paraStart, paraStart + paragraph.length(),"P"));
    paraStart += paragraph.length() + paraSeparatorLength;
    }
    
  6. 程序的其余部分涉及打印出带有一些标记的段落和句子。首先,我们将创建一个同时包含句子和段落块的块:

    String underlyingString = paraChunking.charSequence().toString();
    ChunkingImpl displayChunking = new ChunkingImpl(paraChunking.charSequence());
    displayChunking.addAll(sentChunking.chunkSet());
    displayChunking.addAll(paraChunking.chunkSet());
    
  7. 接下来,displayChunking 将通过恢复 chunkSet 进行排序,转换为一个块数组,并应用静态比较器:

    Set<Chunk> chunkSet = displayChunking.chunkSet();
    Chunk[] chunkArray = chunkSet.toArray(new Chunk[0]);
    Arrays.sort(chunkArray, Chunk.LONGEST_MATCH_ORDER_COMPARATOR);
    
  8. 我们将使用与 在字符串中标记嵌入块 - 句子块示例 配方中相同的技巧,即将标记反向插入字符串中。我们需要保持一个偏移量计数器,因为嵌套的句子会延长结束段落标记的位置。该方法假设没有块重叠,并且句子始终包含在段落内:

    StringBuilder output = new StringBuilder(underlyingString);
    int sentBoundOffset = 0;
    for (int i = chunkArray.length -1; i >= 0; --i) {
      Chunk chunk = chunkArray[i];
      if (chunk.type().equals("P")) {
        output.insert(chunk.end() + sentBoundOffset,"}");
        output.insert(chunk.start(),"{");
        sentBoundOffset = 0;
      }
      if (chunk.type().equals("S")) {
        output.insert(chunk.end(),"]");
        output.insert(chunk.start(),"[");
        sentBoundOffset += 2;
      }
    }
    System.out.println(output.toString());
    
  9. 这就是该配方的全部内容。

简单的名词短语和动词短语

本配方将展示如何查找简单的名词短语NP)和动词短语VP)。这里的“简单”是指短语内没有复杂结构。例如,复杂的 NP "The rain in Spain" 将被分解成两个简单的 NP 块:“The rain”和“Spain”。这些短语也称为“基础短语”。

本配方不会深入探讨如何计算基础 NP/VP,而是介绍如何使用这个类——它非常实用,如果你想了解它如何工作,可以包括源代码。

如何实现……

和许多其他的配方一样,我们在这里提供一个命令行交互式界面:

  1. 打开命令行并输入:

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar: com.lingpipe.cookbook.chapter5.PhraseChunker
    INPUT> The rain in Spain falls mainly on the plain.
    The/at rain/nn in/in Spain/np falls/vbz mainly/rb on/in the/at plain/jj ./. 
     noun(0,8) The rain
     noun(12,17) Spain
     verb(18,30) falls mainly
     noun(34,43) the plain
    
    

它是如何工作的……

main()方法首先反序列化词性标注器,然后创建tokenizerFactory

public static void main(String[] args) throws IOException, ClassNotFoundException {
  File hmmFile = new File("models/pos-en-general-brown.HiddenMarkovModel");
  HiddenMarkovModel posHmm = (HiddenMarkovModel) AbstractExternalizable.readObject(hmmFile);
  HmmDecoder posTagger  = new HmmDecoder(posHmm);
  TokenizerFactory tokenizerFactory = IndoEuropeanTokenizerFactory.INSTANCE;

接下来,构造PhraseChunker,这是一种启发式的方法来解决该问题。查看源代码了解它是如何工作的——它从左到右扫描输入,查找 NP/VP 的开始,并尝试逐步添加到短语中:

PhraseChunker chunker = new PhraseChunker(posTagger,tokenizerFactory);

我们的标准控制台输入/输出代码如下:

BufferedReader bufReader = new BufferedReader(new InputStreamReader(System.in));
while (true) {
  System.out.print("\n\nINPUT> ");
  String input = bufReader.readLine();

然后,输入被分词,词性标注,并打印出标记和标签:

Tokenizer tokenizer = tokenizerFactory.tokenizer(input.toCharArray(),0,input.length());
String[] tokens = tokenizer.tokenize();
List<String> tokenList = Arrays.asList(tokens);
Tagging<String> tagging = posTagger.tag(tokenList);
for (int j = 0; j < tokenList.size(); ++j) {
  System.out.print(tokens[j] + "/" + tagging.tag(j) + " ");
}
System.out.println();

然后计算并打印 NP/VP 的分块结果:

Chunking chunking = chunker.chunk(input);
CharSequence cs = chunking.charSequence();
for (Chunk chunk : chunking.chunkSet()) {
  String type = chunk.type();
  int start = chunk.start();
  int end = chunk.end();
  CharSequence text = cs.subSequence(start,end);
  System.out.println("  " + type + "(" + start + ","+ end + ") " + text);
  }

这里有一个更全面的教程,访问alias-i.com/lingpipe/demos/tutorial/posTags/read-me.html

基于正则表达式的 NER 分块

命名实体识别NER)是识别文本中具体事物提及的过程。考虑一个简单的名称;位置命名实体识别器可能会在以下文本中分别找到Ford PrefectGuildford作为人名和地名:

Ford Prefect used to live in Guildford before he needed to move.

我们将从构建基于规则的 NER 系统开始,逐步过渡到机器学习方法。这里,我们将构建一个能够从文本中提取电子邮件地址的 NER 系统。

如何实现……

  1. 在命令提示符中输入以下命令:

    java –cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar com.lingpipe.cookbook.chapter5.RegexNer
    
    
  2. 与程序的交互如下进行:

    Enter text, . to quit:
    >Hello,my name is Foo and my email is foo@bar.com or you can also contact me at foo.bar@gmail.com.
    input=Hello,my name is Foo and my email is foo@bar.com or you can also contact me at foo.bar@gmail.com.
    chunking=Hello,my name is Foo and my email is foo@bar.com or you can also contact me at foo.bar@gmail.com. : [37-48:email@0.0, 79-96:email@0.0]
     chunk=37-48:email@0.0  text=foo@bar.com
     chunk=79-96:email@0.0  text=foo.bar@gmail.com
    
    
  3. 你可以看到foo@bar.comfoo.bar@gmail.com都被识别为有效的e-mail类型块。此外,请注意,句子末尾的句号不是第二个电子邮件地址的一部分。

它是如何工作的……

正则表达式分块器查找与给定正则表达式匹配的块。本质上,java.util.regex.Matcher.find()方法用于迭代地查找匹配的文本片段,然后将这些片段转换为 Chunk 对象。RegExChunker类包装了这些步骤。src/com/lingpipe/cookbook/chapter5/RegExNer.java的代码如下所述:

public static void main(String[] args) throws IOException {
  String emailRegex = "A-Za-z0-9*)" + + "@([A-Za-z0-9]+)" + "(([\\.\\-]?[a-zA-Z0-9]+)*)\\.([A-Za-z]{2,})";
  String chunkType = "email";
  double score = 1.0;
  Chunker chunker = new RegExChunker(emailRegex,chunkType,score);

所有有趣的工作都在前面的代码行中完成。emailRegex是从互联网上获取的——参见以下源代码,其余的部分是在设置chunkTypescore

其余代码会读取输入并输出分块结果:

BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
  String input = "";
  while (true) {
    System.out.println("Enter text, . to quit:");
    input = reader.readLine();
    if(input.equals(".")){
      break;
    }
    Chunking chunking = chunker.chunk(input);
    System.out.println("input=" + input);
    System.out.println("chunking=" + chunking);
    Set<Chunk> chunkSet = chunking.chunkSet();
    Iterator<Chunk> it = chunkSet.iterator();
    while (it.hasNext()) {
      Chunk chunk = it.next();
      int start = chunk.start();
      int end = chunk.end();
      String text = input.substring(start,end);
      System.out.println("     chunk=" + chunk + " text=" + text);
    }
  }
}

另见

基于词典的命名实体识别(NER)分块

在许多网站和博客,特别是在网络论坛上,你可能会看到关键词高亮,这些关键词链接到你可以购买产品的页面。同样,新闻网站也提供关于人物、地点和流行事件的专题页面,例如www.nytimes.com/pages/topics/

其中许多操作是完全自动化的,通过基于词典的Chunker很容易实现。编译实体名称及其类型的列表非常简单。精确的字典分块器根据分词后的字典条目的精确匹配来提取分块。

LingPipe 中基于字典的分块器的实现基于 Aho-Corasick 算法,该算法在线性时间内找到所有与字典匹配的项,无论匹配数量或字典大小如何。这使得它比做子字符串搜索或使用正则表达式的天真方法更高效。

如何操作……

  1. 在你选择的 IDE 中运行chapter5包中的DictionaryChunker类,或者在命令行中输入以下命令:

    java –cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar com.lingpipe.cookbook.chapter5.DictionaryChunker
    
    
  2. 由于这个特定的分块器示例强烈偏向于《银河系漫游指南》,我们使用一个涉及一些角色的句子:

    Enter text, . to quit:
    Ford and Arthur went up the bridge of the Heart of Gold with Marvin
    CHUNKER overlapping, case sensitive
     phrase=|Ford| start=0 end=4 type=PERSON score=1.0
     phrase=|Arthur| start=9 end=15 type=PERSON score=1.0
     phrase=|Heart| start=42 end=47 type=ORGAN score=1.0
     phrase=|Heart of Gold| start=42 end=55 type=SPACECRAFT score=1.0
     phrase=|Marvin| start=61 end=67 type=ROBOT score=1.0
    
    
  3. 请注意,我们有来自HeartHeart of Gold的重叠部分。正如我们将看到的,这可以配置为不同的行为方式。

它是如何工作的……

基于字典的 NER 驱动了大量的自动链接,针对非结构化文本数据。我们可以使用以下步骤构建一个。

代码的第一步将创建MapDictionary<String>来存储字典条目:

static final double CHUNK_SCORE = 1.0;

public static void main(String[] args) throws IOException {
  MapDictionary<String> dictionary = new MapDictionary<String>();
  MapDictionary<String> dictionary = new MapDictionary<String>();

接下来,我们将用DictionaryEntry<String>填充字典,其中包括类型信息和将用于创建分块的得分:

dictionary.addEntry(new DictionaryEntry<String>("Arthur","PERSON",CHUNK_SCORE));
dictionary.addEntry(new DictionaryEntry<String>("Ford","PERSON",CHUNK_SCORE));
dictionary.addEntry(new DictionaryEntry<String>("Trillian","PERSON",CHUNK_SCORE));
dictionary.addEntry(new DictionaryEntry<String>("Zaphod","PERSON",CHUNK_SCORE));
dictionary.addEntry(new DictionaryEntry<String>("Marvin","ROBOT",CHUNK_SCORE));
dictionary.addEntry(new DictionaryEntry<String>("Heart of Gold", "SPACECRAFT",CHUNK_SCORE));
dictionary.addEntry(new DictionaryEntry<String>("HitchhikersGuide", "PRODUCT",CHUNK_SCORE));

DictionaryEntry构造函数中,第一个参数是短语,第二个字符串参数是类型,最后一个双精度参数是分块的得分。字典条目始终区分大小写。字典中没有限制不同实体类型的数量。得分将作为分块得分传递到基于字典的分块器中。

接下来,我们将构建Chunker

boolean returnAllMatches = true;
boolean caseSensitive = true;
ExactDictionaryChunker dictionaryChunker = new ExactDictionaryChunker(dictionary, IndoEuropeanTokenizerFactory.INSTANCE, returnAllMatches,caseSensitive);

精确的字典分块器可以配置为提取所有匹配的分块,或者通过returnAllMatches布尔值将结果限制为一致的非重叠分块。查看 Javadoc 以了解精确的标准。还有一个caseSensitive布尔值。分块器需要一个分词器,因为它根据符号匹配分词,并且在匹配过程中会忽略空白字符。

接下来是我们的标准输入/输出代码,用于控制台交互:

BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
String text = "";
while (true) {
  System.out.println("Enter text, . to quit:");
  text = reader.readLine();
  if(text.equals(".")){
    break;
  }

剩余的代码创建了一个分块器,遍历分块,并将它们打印出来:

System.out.println("\nCHUNKER overlapping, case sensitive");
Chunking chunking = dictionaryChunker.chunk(text);
  for (Chunk chunk : chunking.chunkSet()) {
    int start = chunk.start();
    int end = chunk.end();
    String type = chunk.type();
    double score = chunk.score();
    String phrase = text.substring(start,end);
    System.out.println("     phrase=|" + phrase + "|" + " start=" + start + " end=" + end + " type=" + type + " score=" + score);

字典块划分器在基于机器学习的系统中也非常有用。通常,总会有一些实体类别,使用这种方式最容易识别。混合命名实体识别源食谱介绍了如何处理多个命名实体来源。

词语标记与块之间的转换 – BIO 编解码器

在第四章中,标签词语与词元,我们使用了 HMM 和 CRF 来为词语/词元添加标签。本食谱讨论了如何通过使用开始、内含和结束BIO)标签,从标记中创建块,进而编码可能跨越多个词语/词元的块。这也是现代命名实体识别系统的基础。

准备就绪

标准的 BIO 标记方案中,块类型 X 的第一个词元被标记为 B-X(开始),同一块中的所有后续词元被标记为 I-X(内含)。所有不在块中的词元被标记为 O(结束)。例如,具有字符计数的字符串:

John Jones Mary and Mr. Jones
01234567890123456789012345678
0         1         2         

它可以被标记为:

John  B_PERSON
Jones  I_PERSON
Mary  B_PERSON
and  O
Mr    B_PERSON
.    I_PERSON
Jones  I_PERSON

相应的块将是:

0-10 "John Jones" PERSON
11-15 "Mary" PERSON
20-29 "Mr. Jones" PERSON

如何做…

程序将展示标记和块的最简单映射关系,反之亦然:

  1. 运行以下命令:

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar: com.lingpipe.cookbook.chapter5.BioCodec
    
    
  2. 程序首先打印出将被标记的字符串:

    Tagging for :The rain in Spain.
    The/B_Weather
    rain/I_Weather
    in/O
    Spain/B_Place
    ./O
    
    
  3. 接下来,打印出块:

    Chunking from StringTagging
    0-8:Weather@-Infinity
    12-17:Place@-Infinity
    
    
  4. 然后,从刚刚显示的块中创建标记:

    StringTagging from Chunking
    The/B_Weather
    rain/I_Weather
    in/O
    Spain/B_Place
    ./O
    
    

它是如何工作的…

代码首先手动构造StringTagging——我们将在 HMM 和 CRF 中看到同样的程序化操作,但这里是显式的。然后它会打印出创建的StringTagging

public static void main(String[] args) {
  List<String> tokens = new ArrayList<String>();
  tokens.add("The");
  tokens.add("rain");
  tokens.add("in");
  tokens.add("Spain");
  tokens.add(".");
  List<String> tags = new ArrayList<String>();
  tags.add("B_Weather");
  tags.add("I_Weather");
  tags.add("O");
  tags.add("B_Place");
  tags.add("O");
  CharSequence cs = "The rain in Spain.";
  //012345678901234567
  int[] tokenStarts = {0,4,9,12,17};
  int[] tokenEnds = {3,8,11,17,17};
  StringTagging tagging = new StringTagging(tokens, tags, cs, tokenStarts, tokenEnds);
  System.out.println("Tagging for :" + cs);
  for (int i = 0; i < tagging.size(); ++i) {
    System.out.println(tagging.token(i) + "/" + tagging.tag(i));
  }

接下来,它将构造BioTagChunkCodec,并将刚刚打印出来的标记转换为块,然后打印出块:

BioTagChunkCodec codec = new BioTagChunkCodec();
Chunking chunking = codec.toChunking(tagging);
System.out.println("Chunking from StringTagging");
for (Chunk chunk : chunking.chunkSet()) {
  System.out.println(chunk);
}

剩余的代码反转了这一过程。首先,创建一个不同的BioTagChunkCodec,并使用boolean类型的enforceConsistency,如果为true,它会检查由提供的分词器创建的词元是否完全与块的开始和结束对齐。如果没有对齐,根据使用场景,我们可能会得到标记和块之间无法维持的关系:

boolean enforceConsistency = true;
BioTagChunkCodec codec2 = new BioTagChunkCodec(IndoEuropeanTokenizerFactory.INSTANCE, enforceConsistency);
StringTagging tagging2 = codec2.toStringTagging(chunking);
System.out.println("StringTagging from Chunking");
for (int i = 0; i < tagging2.size(); ++i) {
  System.out.println(tagging2.token(i) + "/" + tagging2.tag(i));
}

最后的for循环仅仅打印出由codec2.toStringTagging()方法返回的标记。

还有更多…

本食谱通过最简单的标记与块之间的映射示例进行讲解。BioTagChunkCodec还接受TagLattice<String>对象,生成 n-best 输出,正如后面将在 HMM 和 CRF 块器中展示的那样。

基于 HMM 的命名实体识别(NER)

HmmChunker使用 HMM 对标记化的字符序列进行块划分。实例包含用于该模型的 HMM 解码器和分词器工厂。块划分器要求 HMM 的状态符合块的逐个词元编码。它使用分词器工厂将块分解为词元和标签序列。请参考第四章中的隐马尔可夫模型(HMM) – 词性食谱,标签词语与词元

我们将讨论如何训练HmmChunker并将其用于CoNLL2002西班牙语任务。你可以并且应该使用自己的数据,但这个配方假设训练数据将采用CoNLL2002格式。

训练是通过一个ObjectHandler完成的,ObjectHandler提供了训练实例。

准备工作

由于我们希望训练这个 chunker,我们需要使用计算自然语言学习CoNLL)模式标注一些数据,或者使用公开的模式。为了提高速度,我们选择获取一个在 CoNLL 2002 任务中可用的语料库。

注意

ConNLL 是一个年度会议,赞助一个比赛。2002 年,这个比赛涉及了西班牙语和荷兰语的命名实体识别(NER)。

数据可以从www.cnts.ua.ac.be/conll2002/ner.tgz下载。

类似于我们在前一个配方中展示的内容;让我们来看一下这些数据的样子:

El       O 
Abogado     B-PER 
General     I-PER 
del     I-PER 
Estado     I-PER 
,       O 
Daryl     B-PER 
Williams     I-PER 
,       O

使用这种编码方式,短语El Abogado General del EstadoDaryl Williams被编码为人物(person),其开始和继续的标记分别为 B-PER 和 I-PER。

注意

数据中有一些格式错误,必须修复这些错误,才能让我们的解析器处理它们。在数据目录解压ner.tgz后,你需要进入data/ner/data,解压以下文件,并按照指示进行修改:

esp.train, line 221619, change I-LOC to B-LOC
esp.testa, line 30882, change I-LOC to B-LOC
esp.testb, line 9291, change I-LOC to B-LOC

如何操作……

  1. 使用命令行,输入以下命令:

    java –cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar com.lingpipe.cookbook.chapter5.HmmNeChunker
    
    
  2. 如果模型不存在,它将对 CoNLL 训练数据进行训练。这可能需要一段时间,所以请耐心等待。训练的输出结果将是:

    Training HMM Chunker on data from: data/ner/data/esp.train
    Output written to : models/Conll2002_ESP.RescoringChunker
    Enter text, . to quit:
    
    
  3. 一旦提示输入文本,输入来自 CoNLL 测试集的西班牙语文本:

    La empresa también tiene participación en Tele Leste Celular , operadora móvil de los estados de Bahía y Sergipe y que es controlada por la española Iberdrola , y además es socia de Portugal Telecom en Telesp Celular , la operadora móvil de Sao Paulo .
    Rank   Conf      Span         Type     Phrase
    0      1.0000   (105, 112)    LOC      Sergipe
    1      1.0000   (149, 158)    ORG      Iberdrola
    2      1.0000   (202, 216)    ORG      Telesp Celular
    3      1.0000   (182, 198)    ORG      Portugal Telecom
    4      1.0000   (97, 102)     LOC      Bahía
    5      1.0000   (241, 250)    LOC      Sao Paulo
    6      0.9907   (163, 169)    PER      además
    7      0.9736   (11, 18)      ORG      también
    8      0.9736   (39, 60)      ORG      en Tele Leste Celular
    9      0.0264   (42, 60)      ORG      Tele Leste Celular
    
    
  4. 我们将看到一系列实体、它们的置信度分数、原始句子中的跨度、实体的类型和表示该实体的短语。

  5. 要找出正确的标签,请查看标注过的esp.testa文件,该文件包含了以下标签:

    Tele B-ORG
    Leste I-ORG
    Celular I-ORG
    Bahía B-LOC
    Sergipe B-LOC
    Iberdrola B-ORG
    Portugal B-ORG
    Telecom I-ORG
    Telesp B-ORG
    Celular I-ORG
    Sao B-LOC
    Paulo I-LOC
    
    
  6. 这可以这样理解:

    Tele Leste Celular      ORG
    Bahía                   LOC
    Sergipe                 LOC
    Iberdrola               ORG
    Portugal Telecom        ORG
    Telesp Celular          ORG
    Sao Paulo               LOC
    
    
  7. 所以,我们把所有置信度为 1.000 的实体识别正确,其他的都识别错了。这有助于我们在生产环境中设置阈值。

它是如何工作的……

CharLmRescoringChunker提供了一个基于长距离字符语言模型的 chunker,通过重新评分包含的字符语言模型 HMM chunker 的输出结果来运行。底层的 chunker 是CharLmHmmChunker的一个实例,它根据构造函数中指定的分词器工厂、n-gram 长度、字符数和插值比率进行配置。

让我们从main()方法开始;在这里,我们将设置 chunker,如果模型不存在则进行训练,然后允许输入以提取命名实体:

String modelFilename = "models/Conll2002_ESP.RescoringChunker";
String trainFilename = "data/ner/data/esp.train";

如果你在数据目录中解压了 CoNLL 数据(tar –xvzf ner.tgz),训练文件将位于正确的位置。记得修正esp.train文件第 221619 行的标注。如果你使用其他数据,请修改并重新编译类。

接下来的代码段会训练模型(如果模型不存在),然后加载序列化版本的分块器。如果你对反序列化有疑问,请参见第一章中的反序列化和运行分类器部分,了解更多内容。以下是代码片段:

File modelFile = new File(modelFilename);
if(!modelFile.exists()){
  System.out.println("Training HMM Chunker on data from: " + trainFilename);
  trainHMMChunker(modelFilename, trainFilename);
  System.out.println("Output written to : " + modelFilename);
}

@SuppressWarnings("unchecked")
RescoringChunker<CharLmRescoringChunker> chunker = (RescoringChunker<CharLmRescoringChunker>) AbstractExternalizable.readObject(modelFile);

trainHMMChunker()方法首先进行一些File文件管理,然后设置CharLmRescoringChunker的配置参数:

static void trainHMMChunker(String modelFilename, String trainFilename) throws IOException{
  File modelFile = new File(modelFilename);
  File trainFile = new File(trainFilename);

  int numChunkingsRescored = 64;
  int maxNgram = 12;
  int numChars = 256;
  double lmInterpolation = maxNgram; 
  TokenizerFactory factory
    = IndoEuropeanTokenizerFactory.INSTANCE;

CharLmRescoringChunker chunkerEstimator
  = new CharLmRescoringChunker(factory,numChunkingsRescored,
          maxNgram,numChars,
          lmInterpolation);

从第一个参数开始,numChunkingsRescored设置来自嵌入式Chunker的分块数量,这些分块将重新评分以提高性能。此重新评分的实现可能有所不同,但通常会使用更少的局部信息来改进基本的 HMM 输出,因为它在上下文上有限。maxNgram设置每种分块类型的最大字符数,用于重新评分的字符语言模型,而lmInterpolation决定模型如何进行插值。一个好的值是字符 n-gram 的大小。最后,创建一个分词器工厂。在这个类中有很多内容,更多信息请查阅 Javadoc。

方法中的下一部分将获取一个解析器,我们将在接下来的代码片段中讨论,它接受chunkerEstimatorsetHandler()方法,然后,parser.parse()方法进行实际训练。最后一段代码将模型序列化到磁盘——请参见第一章中的如何序列化 LingPipe 对象—分类器示例部分,了解其中发生的情况:

Conll2002ChunkTagParser parser = new Conll2002ChunkTagParser();
parser.setHandler(chunkerEstimator);
parser.parse(trainFile);
AbstractExternalizable.compileTo(chunkerEstimator,modelFile);

现在,让我们来看看如何解析 CoNLL 数据。此类的源代码是src/com/lingpipe/cookbook/chapter5/Conll2002ChunkTagParser

public class Conll2002ChunkTagParser extends StringParser<ObjectHandler<Chunking>>
{

  static final String TOKEN_TAG_LINE_REGEX = "(\\S+)\\s(\\S+\\s)?(O|[B|I]-\\S+)";
  static final int TOKEN_GROUP = 1;
  static final int TAG_GROUP = 3;
  static final String IGNORE_LINE_REGEX = "-DOCSTART(.*)";
  static final String EOS_REGEX = "\\A\\Z";
  static final String BEGIN_TAG_PREFIX = "B-";
  static final String IN_TAG_PREFIX = "I-";
  static final String OUT_TAG = "O";

静态方法设置com.aliasi.tag.LineTaggingParser LingPipe 类的配置。像许多可用的数据集一样,CoNLL 使用每行一个标记/标签的格式,这种格式非常容易解析:

private final LineTaggingParser mParser = new LineTaggingParser(TOKEN_TAG_LINE_REGEX, TOKEN_GROUP, TAG_GROUP, IGNORE_LINE_REGEX, EOS_REGEX);

LineTaggingParser构造函数需要一个正则表达式,通过分组识别标记和标签字符串。此外,还有一个正则表达式用于忽略的行,最后一个正则表达式用于句子的结束。

接下来,我们设置TagChunkCodec;它将处理从 BIO 格式的标记令牌到正确分块的映射。关于这里发生的过程,请参见前一个食谱,在词标记和分块之间转换—BIO 编解码器。剩余的参数将标签自定义为与 CoNLL 训练数据的标签相匹配:

private final TagChunkCodec mCodec = new BioTagChunkCodec(null, false, BEGIN_TAG_PREFIX, IN_TAG_PREFIX, OUT_TAG);

该类的其余部分提供parseString()方法,立即将其传递给LineTaggingParser类:

public void parseString(char[] cs, int start, int end) {
  mParser.parseString(cs,start,end);
}

接下来,ObjectHandler解析器与编解码器和提供的处理器一起正确配置:

public void setHandler(ObjectHandler<Chunking> handler) {

  ObjectHandler<Tagging<String>> taggingHandler = TagChunkCodecAdapters.chunkingToTagging(mCodec, handler);
  mParser.setHandler(taggingHandler);
}

public TagChunkCodec getTagChunkCodec(){
  return mCodec;
}

这些代码看起来很奇怪,但实际上它们的作用是设置一个解析器,从输入文件中读取行并从中提取分块。

最后,让我们回到main方法,看看输出循环。我们将把MAX_NBEST块值设置为 10,然后调用块器的nBestChunkings方法。这将提供前 10 个块及其概率分数。根据评估结果,我们可以选择在某个特定分数处进行截断:

char[] cs = text.toCharArray();
Iterator<Chunk> it = chunker.nBestChunks(cs,0,cs.length, MAX_N_BEST_CHUNKS);
System.out.println(text);
System.out.println("Rank          Conf      Span"    + "    Type     Phrase");
DecimalFormat df = new DecimalFormat("0.0000");

for (int n = 0; it.hasNext(); ++n) {

Chunk chunk = it.next();
double conf = chunk.score();
int start = chunk.start();
int end = chunk.end();
String phrase = text.substring(start,end);
System.out.println(n + " "       + "            "   + df.format(conf)     + "       (" + start  + ", " + end  + ")    " + chunk.type()      + "         " + phrase);
}

还有更多内容……

欲了解如何运行完整评估的更多细节,请参见教程中的评估部分:alias-i.com/lingpipe/demos/tutorial/ne/read-me.html

另见

有关CharLmRescoringChunkerHmmChunker的更多详情,请参见:

混合 NER 源

现在我们已经看过如何构建几种不同类型的命名实体识别(NER),接下来可以看看如何将它们组合起来。在本教程中,我们将结合正则表达式块器、基于词典的块器和基于 HMM 的块器,并将它们的输出合并,看看重叠情况。

我们将以与前几个食谱中相同的方式初始化一些块器,然后将相同的文本传递给这些块器。最简单的情况是每个块器返回唯一的输出。例如,我们考虑一个句子:“总统奥巴马原定于今晚在 G-8 会议上发表演讲”。如果我们有一个人名块器和一个组织块器,我们可能只会得到两个唯一的块。然而,如果我们再加入一个美国总统块器,我们将得到三个块:PERSONORGANIZATIONPRESIDENT。这个非常简单的食谱将展示一种处理这些情况的方法。

如何操作……

  1. 使用命令行或 IDE 中的等效命令,输入以下内容:

    java –cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar com.lingpipe.cookbook.chapter5.MultipleNer
    
    
  2. 常见的交互式提示如下:

    Enter text, . to quit:
    President Obama is scheduled to arrive in London this evening. He will address the G-8 summit.
    neChunking: [10-15:PERSON@-Infinity, 42-48:LOCATION@-Infinity, 83-86:ORGANIZATION@-Infinity]
    pChunking: [62-66:MALE_PRONOUN@1.0]
    dChunking: [10-15:PRESIDENT@1.0]
    ----Overlaps Allowed
    
     Combined Chunks:
    [83-86:ORGANIZATION@-Infinity, 10-15:PERSON@-Infinity, 10-15:PRESIDENT@1.0, 42-48:LOCATION@-Infinity, 62-66:MALE_PRONOUN@1.0]
    
    ----Overlaps Not Allowed
    
     Unique Chunks:
    [83-86:ORGANIZATION@-Infinity, 42-48:LOCATION@-Infinity, 62-66:MALE_PRONOUN@1.0]
    
     OverLapped Chunks:
    [10-15:PERSON@-Infinity, 10-15:PRESIDENT@1.0]
    
    
  3. 我们看到来自三个块器的输出:neChunking是经过训练返回 MUC-6 实体的 HMM 块器的输出,pChunking是一个简单的正则表达式,用于识别男性代词,dChunking是一个词典块器,用于识别美国总统。

  4. 如果允许重叠,我们将在合并的输出中看到PRESIDENTPERSON的块。

  5. 如果不允许重叠,它们将被添加到重叠块集合中,并从唯一块中移除。

它是如何工作的……

我们初始化了三个块器,这些块器应该是您从本章之前的食谱中熟悉的:

Chunker pronounChunker = new RegExChunker(" He | he | Him | him", "MALE_PRONOUN",1.0);
File MODEL_FILE = new File("models/ne-en-news.muc6." + "AbstractCharLmRescoringChunker");
Chunker neChunker = (Chunker) AbstractExternalizable.readObject(MODEL_FILE);

MapDictionary<String> dictionary = new MapDictionary<String>();
dictionary.addEntry(
  new DictionaryEntry<String>("Obama","PRESIDENT",CHUNK_SCORE));
dictionary.addEntry(
  new DictionaryEntry<String>("Bush","PRESIDENT",CHUNK_SCORE));
ExactDictionaryChunker dictionaryChunker = new ExactDictionaryChunker(dictionary, IndoEuropeanTokenizerFactory.INSTANCE);

现在,我们将通过所有三个块器对输入文本进行分块,将块合并为一个集合,并将getCombinedChunks方法传递给它:

Set<Chunk> neChunking = neChunker.chunk(text).chunkSet();
Set<Chunk> pChunking = pronounChunker.chunk(text).chunkSet();
Set<Chunk> dChunking = dictionaryChunker.chunk(text).chunkSet();
Set<Chunk> allChunks = new HashSet<Chunk>();
allChunks.addAll(neChunking);
allChunks.addAll(pChunking);
allChunks.addAll(dChunking);
getCombinedChunks(allChunks,true);//allow overlaps
getCombinedChunks(allChunks,false);//no overlaps

这个食谱的核心在于getCombinedChunks方法。我们将遍历所有的块,检查每一对是否在开始和结束时有重叠。如果它们有重叠且不允许重叠,就将它们添加到重叠集;否则,添加到合并集:

static void getCombinedChunks(Set<Chunk> chunkSet, boolean allowOverlap){
  Set<Chunk> combinedChunks = new HashSet<Chunk>();
  Set<Chunk>overLappedChunks = new HashSet<Chunk>();
  for(Chunk c : chunkSet){
    combinedChunks.add(c);
    for(Chunk x : chunkSet){
      if (c.equals(x)){
        continue;
      }
      if (ChunkingImpl.overlap(c,x)) {
        if (allowOverlap){
          combinedChunks.add(x);
        } else {
          overLappedChunks.add(x);
          combinedChunks.remove(c);
        }
      }
    }
  }
}

这是添加更多重叠块规则的地方。例如,你可以基于分数进行评分,如果PRESIDENT块类型的分数高于基于 HMM 的块类型,你可以选择它。

用于分块的 CRF

CRF 最著名的是在命名实体标注方面提供接近最先进的性能。本食谱将告诉我们如何构建这样的系统。该食谱假设你已经阅读、理解并尝试过条件随机场 – 用于词汇/标记标注的第四章,该章节涉及了基础技术。与 HMM 类似,CRF 将命名实体识别视为一个词汇标注问题,具有一个解释层,提供分块信息。与 HMM 不同,CRF 使用基于逻辑回归的分类方法,这使得可以包含随机特征。此外,本食谱遵循了一个优秀的 CRF 教程(但省略了细节),教程地址是alias-i.com/lingpipe/demos/tutorial/crf/read-me.html。Javadoc 中也有很多信息。

准备工作

就像我们之前做的那样,我们将使用一个小型手动编码的语料库作为训练数据。该语料库位于src/com/lingpipe/cookbook/chapter5/TinyEntityCorpus.java,开始于:

public class TinyEntityCorpus extends Corpus<ObjectHandler<Chunking>> {

  public void visitTrain(ObjectHandler<Chunking> handler) {
    for (Chunking chunking : CHUNKINGS) handler.handle(chunking);
  }

  public void visitTest(ObjectHandler<Chunking> handler) {
    /* no op */
  }

由于我们仅使用此语料库进行训练,visitTest()方法没有任何作用。然而,visitTrain()方法将处理程序暴露给CHUNKINGS常量中存储的所有分块。这看起来像以下内容:

static final Chunking[] CHUNKINGS = new Chunking[] {
  chunking(""), chunking("The"), chunking("John ran.", chunk(0,4,"PER")), chunking("Mary ran.", chunk(0,4,"PER")), chunking("The kid ran."), chunking("John likes Mary.", chunk(0,4,"PER"), chunk(11,15,"PER")), chunking("Tim lives in Washington", chunk(0,3,"PER"), chunk(13,23,"LOC")), chunking("Mary Smith is in New York City", chunk(0,10,"PER"), chunk(17,30,"LOC")), chunking("New York City is fun", chunk(0,13,"LOC")), chunking("Chicago is not like Washington", chunk(0,7,"LOC"), chunk(20,30,"LOC"))
};

我们还没有完成。由于Chunking的创建相对冗长,存在静态方法来帮助动态创建所需的对象:

static Chunking chunking(String s, Chunk... chunks) {
  ChunkingImpl chunking = new ChunkingImpl(s);
  for (Chunk chunk : chunks) chunking.add(chunk);
  return chunking;
}

static Chunk chunk(int start, int end, String type) {
  return ChunkFactory.createChunk(start,end,type);
}

这就是所有的设置;接下来,我们将使用前面的数据训练并运行 CRF。

如何操作...

  1. 在命令行中键入TrainAndRunSimplCrf类,或者在你的 IDE 中运行相应的命令:

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar: com.lingpipe.cookbook.chapter5.TrainAndRunSimpleCrf
    
    
  2. 这会导致大量的屏幕输出,报告 CRF 的健康状态和进展,主要是来自底层的逻辑回归分类器,它驱动了整个过程。最有趣的部分是我们将收到一个邀请,去体验新的 CRF:

    Enter text followed by new line
    >John Smith went to New York.
    
    
  3. 分块器报告了第一个最佳输出:

    FIRST BEST
    John Smith went to New York. : [0-10:PER@-Infinity, 19-27:LOC@-Infinity]
    
    
  4. 前述输出是 CRF 的最优分析,展示了句子中有哪些实体。它认为John SmithPER,其输出为the 0-10:PER@-Infinity。我们知道它通过从输入文本中提取从 0 到 10 的子字符串来应用于John Smith。忽略–Infinity,它是为没有分数的片段提供的。最优片段分析没有分数。它认为文本中的另一个实体是New York,其类型为LOC

  5. 紧接着,条件概率跟随其后:

    10 BEST CONDITIONAL
    Rank log p(tags|tokens)  Tagging
    0    -1.66335590 [0-10:PER@-Infinity, 19-27:LOC@-Infinity]
    1    -2.38671498 [0-10:PER@-Infinity, 19-28:LOC@-Infinity]
    2    -2.77341747 [0-10:PER@-Infinity]
    3    -2.85908677 [0-4:PER@-Infinity, 19-27:LOC@-Infinity]
    4    -3.00398856 [0-10:PER@-Infinity, 19-22:LOC@-Infinity]
    5    -3.23050827 [0-10:PER@-Infinity, 16-27:LOC@-Infinity]
    6    -3.49773765 [0-10:PER@-Infinity, 23-27:PER@-Infinity]
    7    -3.58244582 [0-4:PER@-Infinity, 19-28:LOC@-Infinity]
    8    -3.72315571 [0-10:PER@-Infinity, 19-22:PER@-Infinity]
    9    -3.95386735 [0-10:PER@-Infinity, 16-28:LOC@-Infinity]
    
    
  6. 前述输出提供了整个短语的 10 个最佳分析结果及其条件(自然对数)概率。在这种情况下,我们会发现系统对任何分析结果都没有特别的信心。例如,最优分析被正确的估计概率为exp(-1.66)=0.19

  7. 接下来,在输出中,我们看到个别片段的概率:

    MARGINAL CHUNK PROBABILITIES
    Rank Chunk Phrase
    0 0-10:PER@-0.49306887565189683 John Smith
    1 19-27:LOC@-1.1957935770408703 New York
    2 0-4:PER@-1.3270942262839682 John
    3 19-22:LOC@-2.484463373596263 New
    4 23-27:PER@-2.6919267821139776 York
    5 16-27:LOC@-2.881057607295971 to New York
    6 11-15:PER@-3.0868632773744222 went
    7 16-18:PER@-3.1583044940140192 to
    8 19-22:PER@-3.2036305275847825 New
    9 23-27:LOC@-3.536294896211011 York
    
    
  8. 与之前的条件输出一样,概率是对数,因此我们可以看到John Smith片段的估计概率为exp(-0.49) = 0.61,这很有道理,因为在训练时,CRF 看到John出现在PER的开始位置,Smith出现在另一个PER的结束位置,而不是直接看到John Smith

  9. 前述类型的概率分布如果有足够的资源去考虑广泛的分析范围以及结合证据的方式,以允许选择不太可能的结果,确实能改善系统。最优分析往往会过于保守,适应训练数据的外观。

它是如何工作的…

src/com/lingpipe/cookbook/chapter5/TrainAndRunSimpleCRF.java中的代码与我们的分类器和 HMM 配方类似,但有一些不同之处。这些不同之处如下所示:

public static void main(String[] args) throws IOException {
  Corpus<ObjectHandler<Chunking>> corpus = new TinyEntityCorpus();

  TokenizerFactory tokenizerFactory = IndoEuropeanTokenizerFactory.INSTANCE;
  boolean enforceConsistency = true;
  TagChunkCodec tagChunkCodec = new BioTagChunkCodec(tokenizerFactory, enforceConsistency);

当我们之前使用 CRF 时,输入数据是Tagging<String>类型。回顾TinyEntityCorpus.java,数据类型是Chunking类型。前述的BioTagChunkCodec通过提供的TokenizerFactoryboolean来帮助将Chunking转换为Tagging,如果TokenizerFactoryChunk的开始和结束不完全匹配,则会引发异常。回顾在词语标注和片段之间的转换–BIO 编解码器配方,以更好理解这个类的作用。

让我们看一下以下内容:

John Smith went to New York City. : [0-10:PER@-Infinity, 19-32:LOC@-Infinity]

这个编解码器将转化为一个标注:

Tok    Tag
John   B_PER
Smith  I_PER
went  O
to     O
New    B_LOC
York  I_LOC
City  I_LOC
.    O

编解码器也将执行相反的操作。Javadoc 值得一看。一旦建立了这种映射,剩下的 CRF 与背后的词性标注案例是相同的,正如我们在条件随机场 – 用于词语/标记标注的配方中所展示的那样,参见第四章,标注词语和标记。考虑以下代码片段:

ChainCrfFeatureExtractor<String> featureExtractor = new SimpleCrfFeatureExtractor();

所有的机制都隐藏在一个新的 ChainCrfChunker 类中,它的初始化方式类似于逻辑回归,这是其底层技术。如需了解更多配置信息,请参阅 第三章中的 逻辑回归 示例,高级分类器

int minFeatureCount = 1;
boolean cacheFeatures = true;
boolean addIntercept = true;
double priorVariance = 4.0;
boolean uninformativeIntercept = true;
RegressionPrior prior = RegressionPrior.gaussian(priorVariance, uninformativeIntercept);
int priorBlockSize = 3;
double initialLearningRate = 0.05;
double learningRateDecay = 0.995;
AnnealingSchedule annealingSchedule = AnnealingSchedule.exponential(initialLearningRate, learningRateDecay);
double minImprovement = 0.00001;
int minEpochs = 10;
int maxEpochs = 5000;
Reporter reporter = Reporters.stdOut().setLevel(LogLevel.DEBUG);
System.out.println("\nEstimating");
ChainCrfChunker crfChunker = ChainCrfChunker.estimate(corpus, tagChunkCodec, tokenizerFactory, featureExtractor, addIntercept, minFeatureCount, cacheFeatures, prior, priorBlockSize, annealingSchedule, minImprovement, minEpochs, maxEpochs, reporter);

这里唯一的新内容是我们刚刚描述的 tagChunkCodec 参数。

一旦训练完成,我们将通过以下代码访问分块器的最佳结果:

System.out.println("\nFIRST BEST");
Chunking chunking = crfChunker.chunk(evalText);
System.out.println(chunking);

条件分块由以下内容提供:

int maxNBest = 10;
System.out.println("\n" + maxNBest + " BEST CONDITIONAL");
System.out.println("Rank log p(tags|tokens)  Tagging");
Iterator<ScoredObject<Chunking>> it = crfChunker.nBestConditional(evalTextChars,0, evalTextChars.length,maxNBest);

  for (int rank = 0; rank < maxNBest && it.hasNext(); ++rank) {
    ScoredObject<Chunking> scoredChunking = it.next();
    System.out.println(rank + "    " + scoredChunking.score() + " " + scoredChunking.getObject().chunkSet());
  }

可以通过以下方式访问各个块:

System.out.println("\nMARGINAL CHUNK PROBABILITIES");
System.out.println("Rank Chunk Phrase");
int maxNBestChunks = 10;
Iterator<Chunk> nBestIt  = crfChunker.nBestChunks(evalTextChars,0, evalTextChars.length,maxNBestChunks);
for (int n = 0; n < maxNBestChunks && nBestIt.hasNext(); ++n) {
  Chunk chunk = nBestChunkIt.next();
  System.out.println(n + " " + chunk + " " + evalText.substring(chunk.start(),chunk.end()));
}

就这些。你已经访问了世界上最优秀的分块技术之一。接下来,我们将向你展示如何改进它。

使用更好特征的 CRFs 进行命名实体识别(NER)

在这个示例中,我们将展示如何为 CRF 创建一个逼真的、尽管不是最先进的、特征集。这些特征将包括标准化的标记、词性标签、词形特征、位置特征以及标记的前后缀。将其替换为 CRFs for chunking 示例中的 SimpleCrfFeatureExtractor 进行使用。

如何做到……

该示例的源代码位于 src/com/lingpipe/cookbook/chapter5/FancyCrfFeatureExtractor.java

  1. 打开你的 IDE 或命令提示符,输入:

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar: com.lingpipe.cookbook.chapter5.FancyCrfFeatureExtractor
    
    
  2. 准备好迎接控制台中爆炸性的特征输出。用于特征提取的数据是上一个示例中的 TinyEntityCorpus。幸运的是,第一部分数据仅仅是句子 John ran. 中 "John" 的节点特征:

    Tagging:  John/PN
    Node Feats:{PREF_NEXT_ra=1.0, PREF_Jo=1.0, POS_np=1.0, TOK_CAT_LET-CAP=1.0, SUFF_NEXT_an=1.0, PREF_Joh=1.0, PREF_NEXT_r=1.0, SUFF_John=1.0, TOK_John=1.0, PREF_NEXT_ran=1.0, BOS=1.0, TOK_NEXT_ran=1.0, SUFF_NEXT_n=1.0, SUFF_NEXT_ran=1.0, SUFF_ohn=1.0, PREF_J=1.0, POS_NEXT_vbd=1.0, SUFF_hn=1.0, SUFF_n=1.0, TOK_CAT_NEXT_ran=1.0, PREF_John=1.0}
    
    
  3. 序列中的下一个词汇添加了边缘特征——我们不会展示节点特征:

    Edge Feats:{PREV_TAG_TOKEN_CAT_PN_LET-CAP=1.0, PREV_TAG_PN=1.0}
    
    

它是如何工作的……

与其他示例一样,我们不会讨论那些与之前示例非常相似的部分——这里相关的前一个示例是 第四章中的 Modifying CRFs标记单词和标记。这完全相同,唯一不同的是我们将添加更多特征——可能来自意想不到的来源。

注意

CRFs 的教程涵盖了如何序列化/反序列化这个类。该实现并未覆盖这部分内容。

对象构造方式类似于 第四章中的 Modifying CRFs 示例,标记单词和标记

public FancyCrfFeatureExtractor()
  throws ClassNotFoundException, IOException {
  File posHmmFile = new File("models/pos-en-general" + "brown.HiddenMarkovModel");
  @SuppressWarnings("unchecked") HiddenMarkovModel posHmm = (HiddenMarkovModel)
  AbstractExternalizable.readObject(posHmmFile);

  FastCache<String,double[]> emissionCache = new FastCache<String,double[]>(100000);
  mPosTagger = new HmmDecoder(posHmm,null,emissionCache);
}

构造函数设置了一个带有缓存的词性标注器,并将其传递给 mPosTagger 成员变量。

以下方法几乎不做任何事,只是提供了一个内部的 ChunkerFeatures 类:

public ChainCrfFeatures<String> extract(List<String> tokens, List<String> tags) {
  return new ChunkerFeatures(tokens,tags);
}

ChunkerFeatures 类是更有趣的部分:

class ChunkerFeatures extends ChainCrfFeatures<String> {
  private final Tagging<String> mPosTagging;

  public ChunkerFeatures(List<String> tokens, List<String> tags) {
    super(tokens,tags);
    mPosTagging = mPosTagger.tag(tokens);
  }

mPosTagger 函数用于为类创建时呈现的标记设置 Tagging<String>。这将与 tag()token() 超类方法对齐,并作为节点特征的来源提供词性标签。

现在,我们可以开始特征提取了。我们将从边缘特征开始,因为它们是最简单的:

public Map<String,? extends Number> edgeFeatures(int n, int k) {
  ObjectToDoubleMap<String> feats = new ObjectToDoubleMap<String>();
  feats.set("PREV_TAG_" + tag(k),1.0);
  feats.set("PREV_TAG_TOKEN_CAT_"  + tag(k) + "_" + tokenCat(n-1), 1.0);
  return feats;
}

新的特征以 PREV_TAG_TOKEN_CAT_ 为前缀,示例如 PREV_TAG_TOKEN_CAT_PN_LET-CAP=1.0tokenCat() 方法查看前一个标记的单词形状特征,并将其作为字符串返回。查看 IndoEuropeanTokenCategorizer 的 Javadoc 以了解其具体内容。

接下来是节点特征。这里有许多特征;每个特征将依次呈现:

public Map<String,? extends Number> nodeFeatures(int n) {
  ObjectToDoubleMap<String> feats = new ObjectToDoubleMap<String>();

前面的代码设置了带有适当返回类型的方法。接下来的两行设置了一些状态,以便知道特征提取器在字符串中的位置:

boolean bos = n == 0;
boolean eos = (n + 1) >= numTokens();

接下来,我们将计算当前、前一个和下一个位置的标记类别、标记和词性标注:

String tokenCat = tokenCat(n);
String prevTokenCat = bos ? null : tokenCat(n-1);
String nextTokenCat = eos ? null : tokenCat(n+1);

String token = normedToken(n);
String prevToken = bos ? null : normedToken(n-1);
String nextToken = eos ? null : normedToken(n+1);

String posTag = mPosTagging.tag(n);
String prevPosTag = bos ? null : mPosTagging.tag(n-1);
String nextPosTag = eos ? null : mPosTagging.tag(n+1);

上一个和下一个方法检查我们是否处于句子的开始或结束,并相应地返回null。词性标注来自构造函数中计算并保存的词性标注。

标记方法提供了一些标记规范化,将所有数字压缩为相同类型的值。此方法如下:

public String normedToken(int n) {
  return token(n).replaceAll("\\d+","*$0*").replaceAll("\\d","D");
}

这只是将每个数字序列替换为*D...D*。例如,12/3/08被转换为*DD*/*D*/*DD*

然后,我们将为前一个、当前和后一个标记设置特征值。首先,一个标志表示它是否开始或结束一个句子或内部节点:

if (bos) {
  feats.set("BOS",1.0);
}
if (eos) {
  feats.set("EOS",1.0);
}
if (!bos && !eos) {
  feats.set("!BOS!EOS",1.0);
}

接下来,我们将包括标记、标记类别及其词性:

feats.set("TOK_" + token, 1.0);
if (!bos) {
  feats.set("TOK_PREV_" + prevToken,1.0);
}
if (!eos) {
  feats.set("TOK_NEXT_" + nextToken,1.0);
}
feats.set("TOK_CAT_" + tokenCat, 1.0);
if (!bos) {
  feats.set("TOK_CAT_PREV_" + prevTokenCat, 1.0);
}
if (!eos) {
  feats.set("TOK_CAT_NEXT_" + nextToken, 1.0);
}
feats.set("POS_" + posTag,1.0);
if (!bos) {
  feats.set("POS_PREV_" + prevPosTag,1.0);
}
if (!eos) {
  feats.set("POS_NEXT_" + nextPosTag,1.0);
}

最后,我们将添加前缀和后缀特征,这些特征为每个后缀和前缀(最多指定长度)添加特征:

for (String suffix : suffixes(token)) {
  feats.set("SUFF_" + suffix,1.0);
}
if (!bos) {
  for (String suffix : suffixes(prevToken)) {
    feats.set("SUFF_PREV_" + suffix,1.0);
    if (!eos) {
      for (String suffix : suffixes(nextToken)) {
        feats.set("SUFF_NEXT_" + suffix,1.0);
      }
      for (String prefix : prefixes(token)) {
        feats.set("PREF_" + prefix,1.0);
      }
      if (!bos) {
        for (String prefix : prefixes(prevToken)) {
          feats.set("PREF_PREV_" + prefix,1.0);
      }
      if (!eos) {
        for (String prefix : prefixes(nextToken)) {
          feats.set("PREF_NEXT_" + prefix,1.0);
        }
      }
      return feats;
    }

之后,我们将返回生成的特征映射。

prefixsuffix 函数简单地用一个列表实现:

static int MAX_PREFIX_LENGTH = 4;
  static List<String> prefixes(String s) {
    int numPrefixes = Math.min(MAX_PREFIX_LENGTH,s.length());
    if (numPrefixes == 0) {
      return Collections.emptyList();
    }
    if (numPrefixes == 1) {
      return Collections.singletonList(s);
    }
    List<String> result = new ArrayList<String>(numPrefixes);
    for (int i = 1; i <= Math.min(MAX_PREFIX_LENGTH,s.length()); ++i) {
      result.add(s.substring(0,i));
    }
    return result;
  }

  static int MAX_SUFFIX_LENGTH = 4;
  static List<String> suffixes(String s) {
    int numSuffixes = Math.min(s.length(), MAX_SUFFIX_LENGTH);
    if (numSuffixes <= 0) {
      return Collections.emptyList();
    }
    if (numSuffixes == 1) {
      return Collections.singletonList(s);
    }
    List<String> result = new ArrayList<String>(numSuffixes);
    for (int i = s.length() - numSuffixes; i < s.length(); ++i) {
      result.add(s.substring(i));
    }
    return result;
  }

这是一个很好的特征集,适合你的命名实体检测器。