jieba中文分词的.NET版本:jieba.NET
jieba使用起来非常简单,同时分词的结果也令人印象深刻,有兴趣的可以到它的在线演示站点体验下(注意第三行文字)。
.NET平台上常见的分词组件是盘古分词,但是已经好久没有更新了。最明显的是内置词典,jieba的词典有50万个词条,而盘古的词典是17万,这样会造成明显不同的分词效果。另外,对于未登录词,jieba“采用了基于汉字成词能力的HMM模型,使用了Viterbi算法”,效果看起来也不错。
基于以上两点,加上对于中文分词的兴趣,就尝试将jieba移植到.NET平台上,已经把代码放在了github上:jieba.NET。在试用jieba.NET之前,先简单介绍下jieba的实现思路。
jieba实现浅析
jieba本身提供的文档较少,但我们可以在《对Python中文分词模块结巴分词算法过程的理解和分析》、《jieba 分词源代码研读(1)》这一系列文章中一窥jieba实现的整体思路。简言之,它的核心模块和分词过程大致是:
前缀词典(Trie):用于存储主词典,也可以动态增删词条,这个词典可以理解为jieba所“知道”的词,或者说已登录词;
有向无环图(DAG):通过前缀词典,可以找出句子所有可能的成词结果;
最大概率路径:通过DAG,可以了解所有的成词结果,每个结果对应于一条路径及其概率。由于不同词条的出现概率不同,不同的结果就对应了不同的概率,我们找出概率最大的那条路径。到这里,我们对于已登录词做出了最合理的划分;
HMM模型和Viterbi算法:最大概率路径之后,我们可能会遇到一些未登录词(不包含在前缀词典中的词),这时通过HMM和Viterbi尝试进一步的划分,得到最终结果
这个过程与人的分词过程很类似。比如看到这句话:“语言学家参加学术会议”,我们会把它划分为:“语言学家 参加 学术会议”。尽管这个过程是瞬间完成的,但是它确实包含了上述过程的前三步:分词之前,大脑中已有一个“前缀词典”,它包括语言、语言学、语言学家等等各个词条;大脑知道这句话确实存在多种分词的可能;但它最后还是选出了那个最可能的结果,舍弃了诸如“语言学 家 参加 学术 会议”这样的结果。
前面这句话仅包含了已登录词,再来看另一句话:“他来到了网易杭研大厦”。一般人可以迅速做出划分:“他 来到 了 网易 (杭研)? 大厦”,除了“杭研”这两个字,其它的都属于已登录词,容易划分出来。对于“杭研”,我们要想一想它们是两个单字呢,还是一个新词。最后,可能会伴随着这样一个过程:我知道网易是有研发中心或研究院之类的在杭州的,那么“杭研”可能是与此相关的一个缩写,嗯,又知道了一个新词。尽管这个过程与HMM不同,但我们至少了解到,jieba确实是通过某种方式去尝试寻找未登录词。
不过可以想象的是,基于状态转移概率的HMM模型(建议参考文章《中文分词之HMM模型详解》)能够发现的词应该也是比较自然或正常的词,对于新的人名、机构名或网络词(喜大普奔之类的),效果不会很好。
jieba.NET用法
jieba.NET当前版本是0.37.1,与jieba保持一致,可以通过NuGet安装:
PM> Install-Package jieba.NET
安装之后,把Resources目录copy到程序集所在目录即可。下面分别是分词、词性标注和关键词提取的示例。
分词
var segmenter = new JiebaSegmenter(); var segments = segmenter.Cut("我来到北京清华大学", cutAll: true); Console.WriteLine("【全模式】:{0}", string.Join("/ ", segments)); segments = segmenter.Cut("我来到北京清华大学"); // 默认为精确模式 Console.WriteLine("【精确模式】:{0}", string.Join("/ ", segments)); segments = segmenter.Cut("他来到了网易杭研大厦"); // 默认为精确模式,同时也使用HMM模型 Console.WriteLine("【新词识别】:{0}", string.Join("/ ", segments)); segments = segmenter.CutForSearch("小明硕士毕业于中国科学院计算所,后在日本京都大学深造"); // 搜索引擎模式 Console.WriteLine("【搜索引擎模式】:{0}", string.Join("/ ", segments)); segments = segmenter.Cut("结过婚的和尚未结过婚的"); Console.WriteLine("【歧义消除】:{0}", string.Join("/ ", segments));
运行结果为:
【全模式】:我/ 来到/ 北京/ 清华/ 清华大学/ 华大/ 大学
【精确模式】:我/ 来到/ 北京/ 清华大学
【新词识别】:他/ 来到/ 了/ 网易/ 杭研/ 大厦
【搜索引擎模式】:小明/ 硕士/ 毕业/ 于/ 中国/ 科学/ 学院/ 科学院/ 中国科学院/ 计算/ 计算所/ ,/ 后/ 在/ 日本/ 京都/ 大学/ 日本京都大学/ 深造
【歧义消除】:结过婚/ 的/ 和/ 尚未/ 结过婚/ 的
JiebaSegmenter.Cut方法可通过cutAll来支持两种模式,精确模式和全模式。精确模式是最基础和自然的模式,试图将句子最精确地切开,适合文本分析;而全模式,把句子中所有的可以成词的词语都扫描出来, 速度更快,但是不能解决歧义,因为它不会扫描最大概率路径,也不会通过HMM去发现未登录词。
CutForSearch采用的是搜索引擎模式,在精确模式的基础上对长词再次切分,提高召回率,适合用于搜索引擎分词。
词性标注
词性标注采用和ictclas兼容的标记法,关于ictclas和jieba中使用的标记法列表,请参考:词性标记。
var posSeg = new PosSegmenter(); var s = "一团硕大无朋的高能离子云,在遥远而神秘的太空中迅疾地飘移"; var tokens = posSeg.Cut(s); Console.WriteLine(string.Join(" ", tokens.Select(token => string.Format("{0}/{1}", token.Word, token.Flag))));
运行结果
一团/m 硕大无朋/i 的/uj 高能/n 离子/n 云/ns ,/x 在/p 遥远/a 而/c 神秘/a 的/uj 太空/n 中/f 迅疾/z 地/uv 飘移/v
关键词提取
看下面来自维基百科的关于算法的文字:
在数学和计算机科学/算学之中,算法/算则法(Algorithm)为一个计算的具体步骤,常用于计算、数据处理和自动推理。精确而言,算法是一个表示为有限长列表的有效方法。算法应包含清晰定义的指令用于计算函数。
算法中的指令描述的是一个计算,当其运行时能从一个初始状态和初始输入(可能为空)开始,经过一系列有限而清晰定义的状态最终产生输出并停止于一个终态。一个状态到另一个状态的转移不一定是确定的。随机化算法在内的一些算法,包含了一些随机输入。
形式化算法的概念部分源自尝试解决希尔伯特提出的判定问题,并在其后尝试定义有效计算性或者有效方法中成形。这些尝试包括库尔特・哥德尔、雅克・埃尔布朗和斯蒂芬・科尔・克莱尼分别于1930年、1934年和1935年提出的递归函数,阿隆佐・邱奇于1936年提出的λ演算,1936年Emil Leon Post的Formulation 1和艾伦・图灵1937年提出的图灵机。即使在当前,依然常有直觉想法难以定义为形式化算法的情况。
现在来尝试提取其中的关键词。jieba.NET提供了TF-IDF和TextRank两种算法来提取关键词,TF-IDF对应的类是JiebaNet.Analyser.TfidfExtractor,TextRank的是JiebaNet.Analyser.TextRankExtractor。
var extractor = new TfidfExtractor(); // 提取前十个仅包含名词和动词的关键词 var keywords = extractor.ExtractTags(text, 10, Constants.NounAndVerbPos); foreach (var keyword in keywords) { Console.WriteLine(keyword); }
运行结果是
算法
定义
计算
尝试
形式化
提出
状态
指令
输入
包含
相应的ExtractTagsWithWeight方法的返回结果中除了包含关键词,还包含了相应的权重值。TextRankExtractor的接口与TfidfExtractor完全一致,不再赘述。
小结
分词、词性标注和关键词提取是jieba的三个主要功能模块,jieba.NET目前尽量在功能和接口上与jieba保持一致,但以后可能会在jieba基础上提供其它扩展功能。jieba.NET的开发刚刚开始,还有很多细节需要完善。非常欢迎大家的试用和反馈,也希望能和大家一起讨论,共同实现更好的中文分词库。
jieba.NET与Lucene.Net的集成
看到了两个中文分词与Lucene.Net的集成项目:Lucene.Net.Analysis.PanGu和Lucene.Net.Analysis.MMSeg,参考其中的代码实现了最简单的集成:jiebaForLuceneNet。下面给出简单的介绍。
1、JiebaTokenizer
主要的集成点是自定义一个Tokenizer的子类,此时必须要实现它的抽象方法IncrementToken,该方法用于对文本流中的文本生成的token进行遍历,这正是分词组件发挥作用的地方。
public override bool IncrementToken() { ClearAttributes(); position++; if (position < tokens.Count) { var token = tokens[position]; termAtt.SetTermBuffer(token.Word); offsetAtt.SetOffset(token.StartIndex, token.EndIndex); typeAtt.Type = "Jieba"; return true; } End(); return false; }
termAtt和offsetAtt所在的两行代码需要用到每一个token的词本身、起始索引和终止索引,而这三个值恰好是JiebaSegmenter.Tokenize方法所实现的,所以只要在初始化JiebaTokenizer时使用:
tokens = segmenter.Tokenize(text, TokenizerMode.Search).ToList();
就可以得到所有分词所得的token,另外TokenizerMode.Search参数使得Tokenize方法的结果中包含更全面的分词结果,比如“语言学家”会得到四个token,即“[语言, (0, 2)], [学家, (2, 4)], [语言学, (0, 3)], [语言学家, (0, 4)]”,这在创建索引和搜索时都很有帮助。
2、JiebaAnalyzer
Tokenizer类实现分词,而添加索引和搜索需要的是Analyzer,JiebaAnalyzer只要调用JiebaTokenizer即可。
public override TokenStream TokenStream(string fieldName, TextReader reader) { var seg = new JiebaSegmenter(); TokenStream result = new JiebaTokenizer(seg, reader); // This filter is necessary, because the parser converts the queries to lower case. result = new LowerCaseFilter(result); result = new StopFilter(true, result, StopWords); return result; }
除了JiebaTokenizer,JiebaAnalyzer还会用到LowerCaseFilter和StopFilter。前者可将索引和搜索的内容正则化,忽略大小写,后者则过滤掉停用词。这里使用的停用词列表合并了NLTK的英文停用词和哈工大的中文停用词。
3、创建索引和搜索
创建索引时,IndexWriter要使用JiebaAnalyzer的实例:
var analyzer = new JiebaAnalyzer(); using (var writer = new IndexWriter(Directory, analyzer, IndexWriter.MaxFieldLength.UNLIMITED)) { // replaces older entry if any foreach (var sd in data) { AddToLuceneIndex(sd, writer); } analyzer.Close(); }
搜索的时候,先将用户的输入分词:
private static string GetKeyWordsSplitBySpace(string keywords, JiebaTokenizer tokenizer) { var result = new StringBuilder(); var words = tokenizer.Tokenize(keywords); foreach (var word in words) { if (string.IsNullOrWhiteSpace(word.Word)) { continue; } result.AppendFormat("{0} ", word.Word); } return result.ToString().Trim(); }
比如如果用户输入的是“语言学家”,那么该函数的返回值是“语言 学家 语言学 语言学家”,为后面的搜索做好准备(另外,我们还可以为每个词加上一个*,这样只要部分匹配就可以搜到结果)。最后的搜索实现是:
private static IEnumerableSearchQuery(string searchQuery, string searchField = "") { if (string.IsNullOrEmpty(searchQuery.Replace("*", "").Replace("?", ""))) { return new List (); } using (var searcher = new IndexSearcher(Directory, false)) { var hitsLimit = 1000; //var analyzer = new StandardAnalyzer(Version.LUCENE_30); var analyzer = GetAnalyzer(); if (!string.IsNullOrEmpty(searchField)) { var parser = new QueryParser(Version.LUCENE_30, searchField, analyzer); var query = ParseQuery(searchQuery, parser); var hits = searcher.Search(query, hitsLimit).ScoreDocs; var results = MapLuceneToDataList(hits, searcher); analyzer.Dispose(); return results; } else { var parser = new MultiFieldQueryParser(Version.LUCENE_30, new[] { "Id", "Title", "Content" }, analyzer); var query = ParseQuery(searchQuery, parser); var hits = searcher.Search(query, null, hitsLimit, Sort.RELEVANCE).ScoreDocs; var results = MapLuceneToDataList(hits, searcher); analyzer.Close(); return results; } } }
这里的searchField参数可以指定特定字段进行搜索,如果为空,则对所有字段进行搜索。至此实现了最基本的集成。
JiebaTokenizer、JiebaAnalyzer的实现和示例代码都可在jiebaForLuceneNet找到。
4、Luke.Net
Luke.Net可以查看Lucene.Net生成的索引内容,这在开发和调试Lucene的时候会特别有帮助。