Aho Corasick自动机结合DoubleArrayTrie分词

Aho-Corasick自动机算法(简称AC自动机)1975年产生于贝尔实验室。该算法应用有限自动机巧妙地将字符比较转化为了状态转移。此算法有两个特点,一个是扫描文本时完全不需要回溯,另一个是时间复杂度为O(n),时间复杂度与关键字的数目和长度无关。

DoubleArrayTrie即双数组Trie树在之前的文章中已经介绍过了。

AC自动机结合DoubleArrayTrie可以使得时间和空间利用达到最优。单纯使用DoubleArrayTrie在构建分词时,匹配失败了,会回溯;而AC自动机与DAT树的结合在匹配时一直向前,永不回退,速度会更快一些。

AC自动机的构建需要3样东西:

状态跳转表goto :决定对于当前状态S和条件C,如何得到下一状态S‘
失配跳转表fail : 决定goto表得到的下一状态无效时,应该回退到哪一个状态
匹配结果输出表output : 决定在哪个状态时输出哪个恰好匹配的模式

DAT的构建需要两样东西:

base数组:状态转移
check数组:状态回溯

AC自动机与DAT树的结合主要是使用base与check来替代goto

HanLP实现了该方法,源码在这里

作者主要分为三个步骤:

 //1. 构建二分trie树
addAllKeyword(keySet);

 //2. 在二分trie树的基础上构建双数组trie树
 buildDoubleArrayTrie(keySet);

 //3. 构建failure表并且合并output表
 constructFailureStates();

假设我的词典现在是:

he
hers
his
she

1 构建二分trie树

在这一步主要是读取词典中的词(使用TreeMap读取,可以保证有序性),记录每个词的最后一个词的位置信息。下面以一个图的形式作为展示。

image

0 是根节点
e:0 表示的是词he在e结束,它在词典(TreeMap读取后保存的数据)中的第0个位置
s:1 表示的是词hers在s结束,它在词典(TreeMap读取后保存的数据)中的第1个位置
s:2 表示的是词his在s结束,它在词典(TreeMap读取后保存的数据)中的第2个位置
e:3 表示的是词she在s结束,它在词典(TreeMap读取后保存的数据)中的第3个位置

2 构建双数组trie树

构建双数组trie树的方法跟之前一样,主要构建base数组和check数组。下面以一个图的形式作为展示。

base[s] + c.code = t
check[t] = base[s]

image image

3 构建fail表与output表

fail的构建是根据上一个fail值的成功转移值得到的。

fail(s2) = goto(fail(s1),c)

s2fail值由s1fail根据条件c得到的成功转移值。

Note:离根节点深度为1的状态的fail值都是0,其他值就需要靠公式去计算了。

接着上面的例子,计算各个状态的fail

fail(106)=fail(117)=0
fail(107)=goto(fail(106),e)=goto(0,e)=0
fail(223)=goto(fail(107),r)=goto(0,r)=0
fail(118)=goto(fail(223),s)=goto(0,s)=117
fail(111)=goto(fail(106),i)=goto(0,i)=0
fail(120)=goto(fail(111),s)=goto(0,s)=117
fail(122)=goto(fail(117),h)=goto(0,h)=106
fail(123)=goto(fail(122),e)=goto(106,e)=107

ouput表记录的是在成功或失败转移过程中每个完整的词。

107:0
118:1
120:2
123:3,0

123:3,0表示的是在状态123时有词典(TreeMap读取后保存的数据)中的第0个和第3个词(即he和she)。

由上面的三个过程我们就得到了最为关键的4个信息base,check,fail,output

现在假设有一个字符串ushers,如何分词呢?

下面是HanLP中的部分源码

/*
text:[u,s,h,e,r,s]
*/
public void parseText(char[] text, IHit<V> processor)
    {
        int position = 1;
        int currentState = 0;
        for (char c : text)
        {
            currentState = getState(currentState, c);//获取状态转移节点,成功转移或者失败转移
            int[] hitArray = output[currentState];//该状态是否存在完整的词
            if (hitArray != null)
            {
                for (int hit : hitArray)
                {
                    processor.hit(position - l[hit], position, v[hit]);//记录分割位置,begin,end
                }
            }
            ++position;//一直向前,从不回退
        }
    }


/*******************记录开始词位置***************************/
@Override
            public void hit(int begin, int end)
            {
                int length = end - begin;
                if (length > wordNet[begin])//更新,因为找到了更长的词(这种分词方式是取最长分词)
                {
                    wordNet[begin] = length;//记录每个词的开始位置
                    
                }
            }
            
 /*******************分词***************************/           
for (int i = 0; i < wordNet.length; )//根据wordNet记录的各个词的开始位置,在原句中分词
        {
            Term term = new Term(new String(sentence, i, wordNet[i]));
            term.offset = i;
            termList.add(term);
            i += wordNet[i];
        }            

最后的分词结果就是:[u, she, r, s ]

参考文献:

关系抽取论文解读End-to-End Relation Extraction using LSTMs on Sequences and Tree Structures

关系抽取是从一个句子中判断出这个句子里面两个实体之间的关系。

比如给定句子:

1	"The system as described above has its greatest application in an arrayed <e1>configuration</e1> of antenna <e2>elements</e2>."
Component-Whole(e2,e1)
Comment: Not a collection: there is structure here, organisation.

上面的句子是数据集SemEval 2010 Task 8 数据集 中的一个训练集的实际样本,1表示第一条句子,<e1>configuration</e1> 是指明了实体一, <e2>elements</e2>是指明了实体二,Component-Whole(e2,e1)表明了两个实体之间的关系是Component-Whole关系。Comment是对句子的一些描述信息。

数据集SemEval 2010 Task 8中关系是9种,为了区别正反(Cause-Effect(e1,e2)Cause-Effect(e2,e1)看作是两种关系)和其他(other,有些句子中的实体关系不是给定的关系)一共是19种关系:

(1) Cause-Effect
(2) Instrument-Agency
(3) Product-Producer
(4) Content-Container
(5) Entity-Origin
(6) Entity-Destination
(7) Component-Whole
(8) Member-Collection
(9) Message-Topic

那么我们要根据训练集训练出的模型对句子中的关系进行预测。

A few days before the service, Tom Burris had thrown into Karen's <e1>casket</e1> his wedding <e2>ring</e2>.

上面就是测试例子,请给出两个实体之间的关系。这就是我们要做的关系抽取,现在的关系抽取都是有监督的关系抽取,还有半监督的,还有无监督的(开放域的实体关系抽取),这在深度学习中还是研究热点。

接下来就进入正文:

End-to-End Relation Extraction using LSTMs on Sequences and Tree Structures

【Proceedings of the 54th Annual Meeting of the Association for Computational Linguistics, pages 1105–1116, Berlin, Germany, August 7-12, 2016.c 2016 Association for Computational Linguistics 】

论文成果:

  • 1.实现端到端的实体关系抽取
  • 2.实验表明词序和依存树的结合有利于关系抽取
  • 3.使用Bi Tree LSTM构建网络架构

论文提出了一种端到端的神经网络模型,用于抽取实体和实体间的关系。该方法同时考虑了句子的词序信息和依存句法树的子结构信息,这两部分信息都是利用双向序列LSTM-RNN建模,并将两个模型堆叠起来,使得关系抽取的过程中可以利用实体相关的信息。

论文架构

image

为了能够清晰的理解总体架构,我们需要拆分上述结构,理解基本的RNN

RNN结构:

image

image

image

RNN与DNN的区别就是输入来自两个方面,一个是正常的输入x,一个是上一次隐藏单元的值。

LSTM结构:

image

Bi LSTM 结构

image

Bi RNN 结构

image

双向RNN的前向与后向在本质上没有区别,只是数据颠倒了一下,最终的输出由前向和后向组合。

Tree LSTM 结构

image

树型LSTM的产生是由于依存分析树本身就是一颗树的形式,如果转化为简单的序列结构,可能会丢失信息。

image

树型LSTM与序列LSTM的区别在于有多个来源的输入,一个正常输入x,第一个隐藏单元值h1,第二个隐藏单元值h2……

实验结果:

image 【SemEval 2010 Task 8  数据集】

维特比(viterbi)分词

维特比(viterbi)分词是采用动态规划的思想进行最优分词,局部最优达到全局最优。

现在我们有语句需要进行分词:

有意见分歧。

首先,我们需要用准备好的词典对上述句子进行前缀划分,即找出每个词的所有可能情况。我们得到:

有/有意 , 意/意见 , 见 , 分/分歧 , 。

为了好处理,我们在开始与结尾各自加上一个节点,那么现在的结果是这样的:

image

接下来就可以使用维特比算法找出最优路径。

刚开始,节点都没有连接,那么从第0列到第1列(即从 start有/有意),就直接相连了。此时,我们最新的情况就是这样的了:

image

然后,处理第1列到第2列(即从 有/有意意/意见);先来处理意/意见,他们之间也还没有相连,我们直接连接起来就行,那么现在的图是这样的了:

image

接着处理有意(注意,此时跳到了第3列,因为,有意不能再和意/意见,相连,否则就重复了。具体跳到那一列,是根据当前词的长度来决定的,你可以查看源码。),那么此时的图就是这样的了:

image

现在,我们需要处理第2列到第3列了(即从意/意见),现在节点的前面已经有节点有意了,我们需要判断意 --> 见有意-->见哪一个权重值更小(权重的计算,请看源码,计算出来的结果是有意-->见权重值更小,所以意 --> 见不相连,节点不更新。所以现在的情况跟之前一样。

image

然后是有意分/分歧 直接相连(因为他们还没有跟其他节点连接)。此时图的情况是这样的:

image

按照上面的方式继续处理后面的节点(没有连接的直接相连;已经被连接的就判断当前的连接与之前的连接哪一个权重值更小,保留小的连接),我们可以得到最终的图连接情况。

image

然后从最后一个开始反向回溯,依次找到每个节点的前节点,我们可以得到最终的分词:

image

HanLP维特比分词源码

 private static List<Vertex> viterbi(WordNet wordNet)
    {
        
        LinkedList<Vertex> nodes[] = wordNet.getVertexes();
        LinkedList<Vertex> vertexList = new LinkedList<Vertex>();
        for (Vertex node : nodes[1])//更新起始节点
        {
            node.updateFrom(nodes[0].getFirst());
        }
        for (int i = 1; i < nodes.length - 1; ++i)//遍历节点
        {
            LinkedList<Vertex> nodeArray = nodes[i];
            if (nodeArray == null) continue;
            for (Vertex node : nodeArray)//遍历当前所有前缀节点
            {
                if (node.from == null) continue;
                for (Vertex to : nodes[i + node.realWord.length()])//遍历当前节点的后继节点,具体跳到那一列
                {
                    to.updateFrom(node);//节点权重更新
                }
            }
        }
        Vertex from = nodes[nodes.length - 1].getFirst();//获取最后一个节点
        while (from != null)//反向回溯
        {
            vertexList.addFirst(from);
            from = from.from;
        }
        return vertexList;
    }

/*--------节点权重更新--------*/
 public void updateFrom(Vertex from)
    {
        //计算权重,源码中的计算方式采用了-log(p),所以频率越大,权重值越小。
        double weight = from.weight + MathUtility.calculateWeight(from, this);
        if (this.from == null || this.weight > weight)//选择更小权重的节点
        {
            this.from = from;
            this.weight = weight;
        }
    }

参考资源: