ansj源码解读
早期在项目中使用ansj分词,但一直停留在会用,所以我抽空学习了一下源码,确实对分词的流程和用法有了进一步的理解,在此前我没有学过java,所以看代码的时候很多知识都是请教别人的,所以这里总结可能废话比较多,仅仅是个人通过写来加深理解,希望大家不喜勿喷,总结如下:
一.内容介绍
在介绍ansj的流程前,咱们先看下该工程的目录结构:其中library文件夹中存放自定义的字典、domian文件夹存放着分词相关的实体类,splitword文件夹存放了分词的类,recognition存放了分词后结果的处理类,nature文件夹中存放了词性表,这些都是我会在接下来的博客中会提到的部分,后面会一一进行解读。
ansj工程目录二.domian模块
为了理解起来更连贯,我们先去domian 文件夹中学习一些基本概念。
domian文件夹目录接下来我们会介绍domian package下的部分类
1.Nature 词性实体类
成员常量 构造函数我们可以看到这个类里:
首先定义了一些成员常量,用于存放词性的名称、词性对照表中的位置、词性下标值以及词性的频率,还有一些常量是Nature实体类本身
然后就是构造函数,这里构造函数有两种,第一种是参数是词性名称、词性表中的位置和词性下标值以及词性的频率,另一种是只传词性名称
我们可以看到Nature这个类的构造是通过NatureLibrary.getNature实现的,我们接下来NatureLibrary类的getNature函数的内容
2.NatureLibrary类
因为我们目的是看看Nature类是如何实现的,所以我们先看看getNature这个函数的作用:
可以看到其传入的参数是一个词性字符串,作用是根据词性字符串得到Nature这个类,我们仔细分析一下这个函数的过程:首先参数是词性字符串,然后NATUREMAP这个字典传入这个key值得到对应的value值就是Nature类并赋值给nature变量,后面判断是否nature是null,如果是,就给这个nature赋一个默认值并更新保存到NATUREMAP字典里,好了,到这里我们能看出来NATUREMAP是(key:词性字符串,value:Nature实体类),那么搞清楚Nature的关键就在NATUREMAP字典上,下面我们来看一下这个字典的初始化和赋值过程:
可以看出,这里是初始化了一个空的HashMap<String,Nature>赋值给NATUREMAP变量,下面我们看下这个字典是如何被赋值的:
我们按ctrl+F搜寻这个变量,发现其在init函数里进行了赋值,那么我们来解读一下这个init函数:
首先这个函数的返回值是void即没有返回值,上面是init函数的前半部分,作用是给NATUREMAP这个变量赋值,这部分结构是首先定义了几个变量,然后就是一个try...catch的代码块,主要调用这个MystaticValue这个类的getNatureMapReader函数得到了reader变量,那么这个getNatureMapReader 函数做了什么呢,返回的又是什么呢?
MystaticValue.getNatureMapReader函数我们发现,getNatureMapReader函数里面又调用了dic目录下的类 DicReader类的getReader函数
Dic目录结构 DicReader类getReader函数的参数传的是一个字符串路径:"nature/nature.map",点击这个进去发现是个词性表,词性表的格式如下:
nature/nature.map内容第一列表示词性对照表的位置,第二列表示词性下标值、第三列表示词性名、第四列表示词性的频率。
那么这个getReader函数传这个词性表路径进去具体做了什么呢?接着往下看:
我们进去DicReader类看一下里面的内容,发现这个类主要是用来加载词典的类,这个getReader 函数的作用主要是加载nature/nature.map 这个词性文件中的内容,读取所有的词性字符串和位置以及词频作为返回值,getResourceAsStream是java的一个获取输入流的函数,通过传入文件地址得到了InputStream,这个InputStream 的作用是打开了文件的链接,获取一个流,是字节流即0-1取值的,字节流可以用于读所有文件,然后传到InputStreamReader函数使用utf-8编码创造一个BufferedReader字符流,字符流用于读文本文件,BufferedReader 这个类有readLine方法,然后我们就可以一行一行读了,InputStream没有readLine方法,其实这里的getReader函数其实就是实现了从文件到字符流的封装
到此我们得理一下,也就是看了这么多,就是为了弄清楚MyStaticValue.getNatureMapReader()函数的作用:就是读取了词性表的内容,返回了一个BufferedReader字节流,那么我们接着回来看init函数中getNatureMapReader函数后面的内容:
后面其实就是调用reader的readerLine函数一行一行读取词性表中的内容,把词性表中的词性字符串赋值给NATUREMAP字典的key值,把词性表中的词性的位置、下标值以及词频赋值给Nature类的成员常量,然后把得到的Nature类实例赋值给NATUREMAP字典的value,还有就是这里在读取每一行的时候都把最大的词性下表值赋值给maxLength变量,因此最后这个maxLength变量是词性表中的词性下标值最大的那个值,这个变量后面能用到。到此,我们已经解读了NatureLibrary.getNature函数的过程,也明白了Nature这个实体类的构造过程。
到这里我们基本上已经把Nature这个类解读完了,但是我们不妨再看看NatureLibrary这个类:
我们进入NatureLibrary这个类,发现并没有构造函数,而是有一个静态代码块,对于静态代码块里面的内容是在类初始化的时候初始化一次的,以static关键词开头,发现里面是一个init()函数
然后我们进入这个函数,这个函数就是刚刚我们解读的init函数,刚刚的init函数其实我们只解读了上半部分,我们接着看看下半部分:
加载词性关系上半部分是加载词性表,下半部分是加载词性关系,流程和加载词性表类似,调用了MystaticValue这个类的getNatureTableReader函数:
加载词性表和词性关联表的时候主要用了MystaticValue这个类
该类中几乎所有变化、方法均是静态的。包括以ResourceBundle.getBundle("library")获取library.properties配置文件,读取用户词典路径、歧义词典路径、是否用户辞典不加载相同的词isSkipUserDefine、isRealName。
并读取resources目录下的company、person、newword、nature(词性表、词性关联表)等文件夹中的数据
及resources目录bigramdict.dic(bi-gram模型)、英文词典englishLibrary.dic、数字词典numberLibrary.dic,以及加载crf模型
这个getNatureTableReader函数同样调用了DicReader.getReader函数,只不过此时传入的参数字符串是"nature/nature.table",这是词性关联表,我们看看这个表的内容
看到这里,是不是晕了,我也晕了,这个词性关联表的内容是什么意思呢,好,我们接着往下看吧:
知道了getNatureTableReader函数的作用,接着看如何存储加载进来的词性关联表的:首先初始化一个空的二维int数组给NATURETABLE变量,行和列的长度是init上半部分得到的变量maxLength+1,这就是我们上面解释的变量,是词性表中第二列所有值中的最大值,第二列含义是词性下标值(取值是0-49),也就是nature.map中有50个词性,行数(50行)等同于nature.map中的行数,并且与nature.map相对应,即每行表示的词性同nature.map中的词性。每行中有50个列,即构成50*50的矩阵
那么这个变量NATURETABLE 的每一行存储的就是词性关联表中的一行,由此,加载了整个词性关联表的数据,其实目前我也不知道这个变量里的值是什么意思,那么我们就接着看一下NatureLibrary这个类后面的部分,说不定后面有用到这个函数:
到了这里,是不是明白了,原来这个词性关联表中的值是两个词性之间的频率,也就是说词性关联表中的每个(i,j)位置的数值表示从前一个词的词性i变化到下一个词的词性j的发生频次。用在词性标注工具类NatureRecognition中
到此,我们可以知道NatureLibrary这个类初始化时会读取nature这个文件中nature.map和nature.table两个表的内容,并分别用Nature实体类和NATURETABLE变量存储
接着往后面看:
这个getTwoTermFreq函数是获取两个Term之间的频率,这里的Term类是分词的结果中的每个词的信息会保存为该类,我们发现在在这个类里有:TermNatures词性列表类
这个类是由很多TermNature类组成的:
好,下面我们就先从TermNature类开始介绍
3.TermNature类
这个类里面定义了两个成员变量nature和frequency,还有TermNature实例常量,可以看出是由TermNature类初始化的一个实例,那么我们看构造函数,参数是词性字符串和词频,调用NatureLibrary.getNature函数得到的Nature实体类再赋值给成员变量nature,以及直接将参数frequency参数赋值给了成员变量frequency,由此可知:这个TermNature实体类是存储词的Nature词性类和词频的,至于这个词频和Nature里面的词频有什么关系,代表什么意思目前为止我也不清楚,我们接着往后看。
下面我们再接着看TermNatures实体类
4.TermNatures类
这里首先是初始化了一些TermNatures类实例,有两种初始化方式,主要是参数不同:
第一种构造函数的参数是TermNature类,函数的内容是:第一行对成员变量termNatures初始化为一个含有一个元素的TermNature数组,然后将参数termNature即TermNature类赋值给TermNatures的成员变量termNatures,然后将TermNature类的成员变量nature赋值给TermNatures类的成员变量nature,这样完成了一个TermNatures类的初始化,也就是说这里TermNatures类在初始化的成员变量都是来源于TermNature类;
第二种构造函数的参数比第一种多了两个参数分别是:
可以看出,这里只是比第一个多初始化了TermNatures类的两个参数:allFreq和id,这里的参数allFreq先是赋值给了TermNature的成员变量frequency即词频,再赋值给了TermNatures类的参数id即词的id,这里可以看出来TermNatures类初始化的时也是含有一个元素的TermNature数组
第三种构造函数的参数是一个TermNature数组和词的ID
这是当一个词不止一个词性的情况,此时从for循环可以看出,对于这个词的最终的词性决定是选择最大频率的那个词性的,这个频率可能是分词的文章中计算出来的,很可能是这个词在文章中的频率或者别的,并且最终把这个TermNature数组对应的元素赋值给termNature这个变量,并把这个变量的成员变量Nature赋值给TermNatures这个类的成员变量nature
综上可以看出,这个TermNatures类里面有三种构造函数,传的参数分别是TermNature 数组和TermNature类两种,这是当一个词很多词性和只有一个词性时的情况,因此这个TermNatures这个类的作用主要是存储指定ID的词的词性,并决定最后的词性,即每一个term都拥有一个词性集合
介绍完TermNature类和TermNatures类后,下面介绍Term类
5.Term 类
分词之后的每个词会保存为该类,它会保存词的原本名字、位置、词性列表(一个词可能有多种词性,于是是词性列表)、分数(我也不懂分数是什么鬼)、下一个Term、是否为一个新词等
下面可以看出来:这是toAnalysis.parse(分词函数)分词完以后的结果,存放到了list[Term]里面,可以看出来每个词存为一个Term类
Term类为DAG的节点,字段包括:offe首字符在句子中的位置、name为词,next具有相同首字符的节点、from前驱节点、score打分。
既然这个Term类是分词的结果类,那么我们先看一下分词的过程,毕竟我们的目的就是为了能够运用分词的功能
由上可知:这个Graph类的构造函数参数是一个字符串,然后将·这个字符串转化为字符数组赋值给Graph的成员变量chars,再初始化一个Term类空数组(长度是参数字符串长度+1)给graph的成员变量terms,再初始化了一个Term类实例给Graph类的成员变量end,这个end代表当前Term的起始位置,即是传进来的str的长度,即最后一个,然后初始化一个Term实例赋值给root变量表示graph图的第一个Term,这个root说明首字符在句中的位置是-1,对应的这里的Term类构造函数如下:
可以看到:这里的第一个参数十个词性字符串,大家还有没有印象这个E和B是词性表nature.map的第一行和第二行的第三列即词性字符串,第二个参数是offe在Term类里注释是当前词的起始位置,第三个参数是词性列表,判断这个item即AnsjItem类的成员变量termNatures是否是null,如果不是,就把这个termNatures赋值给Term类的成员变量termNatures,接着判断是否termNatures类的成员变量nature是否是null,如果不是就把nature赋值给Term类的成员变量nature,也就是这个Term类包含了当前词的名字name,起始位置offe,所有的词性列表termNatures以及最终在词性列表中按照词频最高的决定当前词的词性nature。
下面我们稍微了解一下这个AnsjItem类:它是Item的子类,Item类里面方法接班都是抽象的,在AnsjItem类里得到了实现:
AnsjItem类继承了Item类 Item类成员变量 AnsjItem类成员变量 Ansjtem类重写了父类Item方法这个方法传的参数是split字符串数组,第一个元素是index,即词性ID,第二个元素是name,即词性字符串,第三个第四个元素是base,check,第五个元素是status即当前词的状态,status的数值具有如下含义:
1对应的词性为null,name不能单独成词,应继续,比如“振臂一”;
2表示name既可单独成词,也可与其他字符组成新词,比如词“印度”;
3表示词结束,name成词不再继续,比如词“捅娄子”;
4表示英文字母(包括全角)+字符',共计105(26*4+1)个字符;
5表示数字(包括全角)+小数点,共有21(10*2+1)个字符.
那么当status的值大于1即说明name可单独成词时,将split的第五个元素和index传入TermNature类的SetNatureStrToArray方法,我们接下来看看这个方法:
这里我们可以看出:这个词性字符串传进去以后按逗号分割成一个字符串数组,然后每个字符串元素是“词性字符串=词频”形式,将这个词性字符串和词频传入TermNature构造函数得到TermNature实例,作为一个TermNature数组保存
从这里可以看出,Term类的成员变量termNatures即当前词的词性列表是来源于AnsjItem类的initValue方法中对termNatures成员变量进行了赋值,赋值是将可以独立成词的name对应的词性列表通过处理传到了TermNatures类中,成了一个TermNatures类实例,但是此时我们还不知道这个split的来源是什么?以及这里的base和check具体是什么我们也不知道,只能接着往后看:
接着分词函数往下看:
这个AMBIGUITY是个HashMap,this.ambiguityForest是通过AmbiguityLibrary类的get函数获得的Forest类,这个get函数内部判断了这个AMBIGUITY是否有DEFAULT key值,如果没有就返回null,否则就返回default key对应的value值
在Analysis.anlysisStr函数里:首先判断是否启用歧义词典。若是,找出句子中是否包含歧义词。若不存在,对整个句子调用Analysis.analysis;若存在,优先歧义词:以歧义词分隔原句子,根据歧义分词数组中的词及词性逐个添加到graph中,并对非歧义词的部分分别调用Analysis.analysis。Analysis.analysis的过程为按字从DAT中找,通过GetWordsImpl.allWords()查询字在DAT中的base、check等获得状态返回单字或词,调用graph.addTerm添加节点到graph的terms数组中,同时标注是否为数字,英文
这里有三个参数Graph类、起始位置、结束位置,然后进入一个for循环,对Graph的成员变量chars从起始位置到结束位置进行循环,接着进入了一个条件语句,判断status()函数的结果:
这里我们可以看到得到的是一个dat数组,但是我们并不知道这个dat数组具体是什么,只知道这个是DoubleArrayTire类的成员变量,因此我们从DAT对象研究:
这个DAT对象是DoubleArrayTire类,是调用DATDictionary类的loadDAT方法得到的,这个方法里面调用了DoubleArrayTire类的loadText方法:
这个方法里面传了一个字节流InputStream字节流,以及Item的子类,作用是从文本中加载模型,这个InputStream是来自于DicReader.getInputStream(‘core.dic’)得到的,进而调用getReaourceAsStream函数读取core.dic核心字典成一个字节流传进loadText函数
IOUtil.instanceFileIterator函数将字节流转化为一个字符流,下面可以看到函数的实现过程,最后调用了BufferedReader函数将输入的字节流InputsTream转化为字符流便于后面我们读取进行处理:
接下来我们接着看loadText函数,得到了文件的字符串迭代器,下面我们要对得到的内容进行处理,首先我们先看一下core.dic字典的内容:
第一行是这个字典的行数,在loadText函数里也看到把it的第一行转化为int类型然后赋值给了DoubleArrayTire类的成员变量ArrayLength,然后初始化了一个ArrayLength长度的Term数组给DoubleArrayTire类的成员变量dat,接下来就对核心字典里的内容进行循环处理了:
这里我们可以看到对每一行temp都按照tap字符进行分割,得到每一字段的值,然后作为参数传到initValue函数里,将initValue的处理结果item作为dat数组的每个元素,这里的initValue函数就是我们前面分析过的,现在是不是清楚了当时传的那个split变量是什么了,其实就是我们这里的核心字典的每一行,结合initValue函数里的赋值和处理,我们可以知道:核心词典共有6列,分别为
index name base check status {词性->词频}
其中,index表示字符串的id(若为单字符,则为其unicode编码对应的整数值),name为词,base、check分别为DAT的base数组、check数组,status记录当前词的状态,最后一列表示词性集合,对应于类org.ansj.domain.AnsjItem中的成员变量termNatures
到此为止,我们知道了这个dat数组是什么了,她是个Item数组我们是知道的,里面的内容是从核心字典加载的:不过Item的initValue方法是抽象的,子类AnsjItem类对这个方法进行了实现
Item类的方法我们再看这个DAT对象即DoubleArrayTire类在被调用的时候就进行了初始化,即调用了loadText函数,也就是会调用loadText函数,那么就会读取core.dic核心字典,即我们只要调用了DATDictionary类的status方法就会自动加载这个核心字典
我们再看一下这个status这个函数的结果:首先从dat数组中取得c索引位置的Item类,然后判断是否是null,如果是即这个dat数组中没有对应的字符串的id,那么返回0,表明这个字符串不是个词,不是就说明在dat数组中有,就调用Item的getStatus方法得到Item的当前状态status,然后根据这个值决定后面我们该如何处理