标签:__ return self children 词典 state ._ 自然语言 分词
本章节主要讲述单词的切分算法、如何构建并不断优化字典树以及分词的准确率评测。
中文分词指的是将一段文本拆分成一系列单词的过程
文章目录
2.1什么是词
2.1.1词的定义
在基于词典的中文分词中,词典中的字符串就是词,那么,词典之外的字符串就不是词了。
2.1.2词的性质——齐夫定律
一个单词的词频和他的词频排名成反比。
虽然存在很多生词,但是他们的词频排名越靠后,词频越趋近于0,所以,在常见单词的切分上,我们可以相对信任词典分词。
2.2词典
互联网上有很多开源的词典。HanLP中的词典的格式大致如下:
单词 词性1 词频1 词性2 词频2 …
希普曼 nrf 1
希有 a 1
希望 v 7685 vn 616
希望村 ns 2
希杰 nrf 2
当仅进行词典分词时,词性和词频没有任何用处。
2.3切分算法
词典确定后,一个字符串可能可以同时匹配几个单词,如“自然语言”,可能可以匹配单词“自然”,“语言”,“自然语言”,那么,到底切分成哪一个,这需要我们制定规则。常用的规则有,正向最长匹配,逆向最长匹配,双向最长匹配。它们都基于完全切分。
2.3.1完全切分
完全切分是指,找出一段文本中的所有单词,所以,完全切分不是真正意义上的分词。
/**
* 完全切分
* @param text
* @param dictionary
* @return
*/
public static List<String> segmentFully(String text, TreeMap<String, CoreDictionary.Attribute> dictionary){
List<String>wordList = new LinkedList<>();
for(int i =0;i<text.length();i++){
for(int j = i+1;j<text.length()+1;j++){
String word = text.substring(i,j);
if(dictionary.containsKey(word))
wordList.add(word);
}
}
return wordList;
}
2.3.2最长匹配
考虑到越长的单词表达的意义越丰富,于是我们定义单词越长优先级越高。具体来说,就是在以某个下标为起点递增查词的过程中,优先输出更长的单词,这种规则被称为最长匹配。该下标的扫描顺序如果从前往后,则称正向最长匹配,反之,成为逆向最长匹配。
正向最长匹配的Java实现:
/**
* 正向最长匹配
* @param text
* @param dictionary
* @return
*/
public static List<String> segmentForward(String text, TreeMap<String, CoreDictionary.Attribute> dictionary){
List<String>wordList = new LinkedList<>();
//Long start,end;
//start = System.currentTimeMillis();
for(int i =0;i<text.length();){
String longestWord = text.substring(i,i+1);
for(int j = i+1;j<text.length()+1;j++){
String word = text.substring(i,j);
if(dictionary.containsKey(word))
if(word.length()>longestWord.length())
longestWord = word;
}
wordList.add(longestWord);
//因为是最长匹配,我们要移动指向原文本的指针i
i+=longestWord.length();
}
//end = System.currentTimeMillis();
//System.out.println(end -start);
//System.out.println("正向最长匹配的处理速度:" + (text.length())/(end-start)/10 + "万字每秒");
return wordList;
}
逆向最长匹配的java实现:
/**
* 逆向最长匹配
* @param text
* @param dictionary
* @return
*/
public static List<String> segmentBackward(String text, TreeMap<String, CoreDictionary.Attribute> dictionary){
List<String>wordList = new LinkedList<>();
for(int i =text.length()-1;i>=0;){
String longestWord = text.substring(i,i+1);
for(int j = 0;j<=i;j++){
String word = text.substring(j,i+1);
if(dictionary.containsKey(word))
if(word.length()>longestWord.length()) {//进入这个分支就意为着是最长的
longestWord = word;
break;
}
}
//list.add(index,str):在指定索引位置插入元素,将当前位于该位置的元素和任何后续元素右移
wordList.add(0,longestWord);//每次找到的都放置到最前面,这样保证先匹配到的在后面
//因为是最长匹配,我们要移动指向原文本的指针i
i-=longestWord.length();
}
return wordList;
}
能不能从正向匹配和逆向匹配中挑出一个最好的呢?这就是双向最长匹配:
优先级:词少>单字少>逆向最长匹配
Java实现:
/**
* 双向最长匹配
* @param text
* @param dictionary
* @return
*/
public static List<String> segmentBidirectional(String text, TreeMap<String, CoreDictionary.Attribute> dictionary){
List<String>forward = segmentForward(text, dictionary);
List<String>backward = segmentBackward(text, dictionary);
//首先比较词的个数
if(forward.size()<backward.size()){
return forward;
}else if(backward.size()>forward.size()){
return backward;
}else{
if (countSingleChar(forward)<countSingleChar(backward))
return forward;
else return backward;
}
}
/**
* 统计list中有多少个单字词
* @param wordList
* @return
*/
public static int countSingleChar(List<String>wordList){
int count = 0;
for(String str:wordList){
if (str.length()==1)
count++;
}
return count;
}
规则集的维护有时是拆东墙补西墙,有的时候是帮倒忙。
2.4字典树
无论是正向还是逆向匹配,我们发现,比较耗时间的一步是dictionary.containsKey(word),即判断词典中是否含有字符串,这是匹配算法的瓶颈之一。如果用有序集合(TreeMap)的话,复杂度是O(lgn),如果用散列表(HashMap)的话,虽然时间复杂度降低了,但是空间复杂度上去了。所以,我们需要找到速度又快,内存又省的数据结构。
2.4.1什么是字典树
字典树是一颗前缀树,每次只比较一个单字。所谓前缀树,指的是前缀相同的词语必然经过同一个结点。它的实际速度要比二分查找快,因为随着路径的深入,前缀匹配是递进的过程,算法不必比较字符串的前缀。
字典树应该具有增删改查的操作,其中主要是查。
“增加键值对”:其实还是查询,不过是在状态转移失败的时候,我们创建响应的子节点,保证转移成功,然后将插入的值,附加到终点结点上去。
Trie的python实现:
class Node(object):
def __init__(self,value) -> None:
self._children = {}
self._value = value
def _add_child(self,char,value,overwrite = False):
'''
如果存在char对应的Node,且不重写,则直接返回char对应node,不操作。如果要重写,则重写并返回char对应node;
如果不存在,创建node并添加到字典里;
:param char:
:param value:
:param overwrite:
:return:
'''
child = self._children.get(char)
if child is None:
child = Node(value)
self._children[char] = child
elif overwrite:
child._value = value
return child
class Trie(Node):
def __init__(self) -> None:
super().__init__(None)
def __contains__(self, key):
return self[key] is not None
def __getitem__(self, key):
state = self
# 循环递进,不断地深入,修改state的值,获取到最终的node。
for char in key:
state = state._children.get(char)
if state is None:
return None
return state._value
def __setitem__(self, key, value):
state = self
for i,char in enumerate(key):
if(i<len(key)-1):
state = state._add_child(char,None,False)
else:
state = state._add_child(char,value,True)
if __name__ == '__main__':
trie = Trie()
#增
trie['自然'] = 'nature'
trie['自然人'] = 'human'
trie['自然语言'] = 'language'
trie['自语'] = 'talk to oneself'
trie['入门'] = 'introduction'
print(trie.__dict__)
print(trie.__dir__())
print(trie['自然'])
2.4.2首字散列其余二分的字典树
首先,我们来讨论下散列函数,散列函数必须满足的基本要求是:对象相同,散列值必须相同。如果能做到对象不同,散列值也不同,则称作完美散列,不完美散列会导致多个对象被映射到同一位置,不方便索引。即便是完美散列,如果散列值不连续,则必然会造成内存的浪费。可见,如果散列函数设计不当,会导致散列表的内存效率和查找效率都不高。(查找效率不高——非完美散列,存在冲突)
BinTrie的python实现:
# 首字散列其余二分的字典树
def str_hash(str,length = 5):
'''
由于python没有char类型,字符被视作长度为1的字符串
故:
将hash后的值截取到5位长度
:param str:
:param length:
:return:
'''
return abs(hash(str))%(10 ** length)
'''
Node:
属性:
_children:子节点
_key:自己所代表的的字符
_value:自己所代表的结点的值
方法:
__init__:初始化三个属性
_add_child:向children中添加子节点,并且返回子节点的引用
binary_search:搜索children列表中是否包含指定的node,如果包含,则返回对应位置,如果不包含,则创建负数
_compare:比较方法,比较的是node的_key值。
__getitem__:覆写魔术方法。用来查看key是否在children列表里,如果不在,返回None,如果在,返回其值,即self._children[index]
'''
class Node(object):
def __init__(self,key,value)->None:
self._children = [] #子节点存放在list中
self._key = key # 结点代表的字符
self._value = value#结点值
# 子节点的添加方法
def _add_child(self,node):
#需要返回子节点的引用
#二分查询结点位置
idx = self.binary_search(self._children, node)
if idx >= 0:
#找到相同结点,策略是,为None时,赋值,有值时,直接返回
target = self._children[idx]
if target._value == None:
target._value = node._value
return target
else:
#未找到相同结点
insert = -(idx +1) # -(-low-1+1)=low 所以,插入的地方是low
self._children = self._children[:insert]+[node]+self._children[insert:]
return self._children[insert]
#实现二分查询
def binary_search(self,branches,node):
high = len(branches)-1
if len(branches)<1:#表示当前结点没有子节点
return high #len(branches) = 0,所以,此时的high=-1
low = 0
while low <= high:
mid =(low+high) >> 1 #右移一位相当于/2
cmp = self.compare(node,branches[mid])
if cmp<0:
low = mid + 1
elif cmp >0:
high = mid - 1
else:
return mid
return -(low + 1) #返回值小于0,说明没找到
def compare(self,node1,node2):
return str_hash(node2._key)-str_hash(node1)
# 子节点的查询方法
def __getitem__(self,key):
if not self._children:
return None
idx = self.binary_search(self._children,key)
if idx < 0:#没找到
return None
else:
return self._children[idx]
'''
BinTrie:首字散列其余二分的字典树
属性:
_children:用于首字散列的hash数组,因为我们设置的hash值为5位,所以,这个列表初始化为100000个元素足够使用
_size:用来记录有多少个词语
方法:
__init__:初始化两个属性
__setitem__:用于向字典树中添加node。
1.用户输入的'词语'如果是单字,则查看一下_children列表中对应hash位置是否为空,
为空,则根据key和value创建一个结点,加入到_children列表中;
不为空,则替换对用的value。
2.用户输入的词语不是单字,
读取第一个字时,查看_children列表中对应hash位置是否为空,3
为空,则根据key和value创建一个结点,加入到_children列表中;并跟新state(因为要遍历整个字符串的每个字符,要移动指针)
不为空,则直接更新state
读取剩余字时,
不到最后一个字时,通过state[char]--state是Node类型的对象,通过魔术方法调用__getitem__方法,查看char是否在state对应的node的children列表中
state[char]为空:调用_add_child方法向node中添加一个子节点
state[char]不为空:更新state=state[char]
读取最后一个字时,创建一个node结点,其value为调用__setitem__方法时传入的value
__getitem__:用来从字典树中获取词语
依次遍历要获取的词语所组成的字符串的每一个字符,
当遇到对应位置为None时,说明字典树中没有这个词语;
遍历完毕,中途没有return时,返回最终的_value属性对应的值
__contains__:用来判断一个词语是否存在于字典树中,其中会调用__getitem__方法。
存在时,返回true;
不存在时,返回false;
'''
#定义字典树
class BinTrie(object):
def __init__(self) ->None:
self._size = 0
self._children = [None]*100000 #初始化
def __setitem__(self, key, value):
state = self
for i,char in enumerate(key):
if i == 0:#第一层hash查询
if i<len(key) -1: #非词语最后一个字,在这里,i=0,也就是说,词语长度大于1
target = state._children[str_hash(char)]
if target == None:#该位置不存在结点,创建新的子节点
node = Node(char,None)#创建结点
state._children[str_hash(char)] = node#将节点加入到散列表中
state = node
else:#该位置存在结点,则更新state
state = target
else:#词语的最后一个字
if state._children[str_hash(char)] == None:#eg:先定义 自= self,再定义 自然=nature
node = Node(char,value)
state._children[str_hash(char)] = node#将结点加入到散列表中,并更新state
state = node
else:#eg:先定义 自然=nature,再定义 自= self;这时只需要修改对应结点的值,不对’自‘这个结点整体替换。
state._children[str_hash(char)]._value = value
else:
if i<len(key) - 1:#非词语最后一个字
target = state[char]
if target == None:#该位置不存在结点,创建新的子节点
state = state._add_child(Node(char,None))
else:#该位置存在结点,直接返回此结点
state = target
else:#是词语最后一个字
state = state._add_child(Node(char,value))
def __getitem__(self, key):
state = self
for i,char in enumerate(key):
if i==0:
state = state._children[str_hash(char)]
else:
state = state[char]
if state == None:
return None
return state._value
def __contains__(self, key):#判断是否存在
return self[key] is not None
def test():
trie = BinTrie()
#增加
trie['自然'] = 'nature'
trie['自语'] = 'talk to oneself'
trie['自然人'] = 'human'
trie['自'] = 'self'
trie['自然语言'] = 'language'
trie['入门'] = 'introduction'
assert '自然' in trie
print(trie['自然'])
#删除
trie['自然'] = None
print(trie['自然'])
#改
trie['自然语言'] = 'human language'
#assert trie['自然语言'] == 'human language'
print(trie['自然语言'])
#查
assert trie['入门'] == 'introduction'
print(trie['入门'])
2.5双数组字典树
首先分析BinTrie的弱点:除了根节点的完美散列外,其余结点都在用二分查找,当存在c个子节点时,每次状态转移的复杂度为O(logc),当c很大时,依旧很慢。
双数组字典树(Double Array Trie——DAT)就是这样一种状态转移复杂度为常数的数据结构。
核心思想: 由base和check两个数组构成(base和check的索引表示一个状态),缩短状态转移过程的时间。
具体的,当状态b接受字符c转移到状态p时,满足条件(状态由整数下标表示):
state[p] = base[state[b]] + index[c]
check[state[p]] == state[b]
若条件不满足则转换失败。
如:当前状态自然(例如state[自然]=1),若想判断是否可以转移到状态自然人,先执行state[自然人] = base[state[自然]] + index[人] = base[1] + index[人],然后判断check[state[自然人]] == state[自然]是否成立即可,仅需一次加法和整数比较就能进行状态转移,转移过程为常数事件。
具体实现及原理分析,可移步这里进行学习。
2.6AC自动机
虽然DAT每次状态转移的时间复杂度为常数级别,但是全切分长度为n的文本时,复杂度是O(n²).因为在扫描过程中,需要不断挪动起点,发起新的查询。那么,能否一次扫描就查询出所有出现过的单词呢?
给定多个词语(也称模式串,pattern),从母文本中匹配它们的问题成为多模式匹配。
BinTrie扫描“自然语言处理入门”这句话时,只有“自然”存在时,“自然语言”“自然语言处理”才可能存在。但算法以“自”为起点扫描“自”“自然”“自然语”…“自然语言处理入门”后,又得回退到“然”继续扫描“然语”“然语言”…
如果在扫描到“自然语言”的同时想办法知道,“然语言”“语言”“言”是否在字典树中,就可以省略掉这三次查询。我们发现这三个字符串,它们共享递进式的后缀。
AC自动机在前缀树的基础上,为前缀树的每个节点建立一颗后缀树,节省了大量时间。
AC自动机由goto表、fail表和output表组成。
goto表就是前缀树。
fail表保存的是状态间一对一的关系,存储状态转移失败后应当回退的最佳状态。最佳状态指的是能记住已匹配上的字符串的最长后缀的那个状态。
output表是用来存储一个状态与其对应的某个或某些模式串的一个关联结构。
2.7基于双数组字典树的AC自动机
AhoCorasickDoubleArrayTrie(ACDAT)
goto表本身是一个前缀树,所以,我们可以用DAT来实现它。
由于汉语中的词汇都不长,有些甚至是单字词,这就使得前缀树的优势占了较大比重,AC自动机的fail机制没有太大的用武之地。所以ACDAT的性能和DAT不相上下。
总结:当含有短模式串时,优先用DAT,否则优先用ACDAT。
2.8HanLP的词典分词实现
HanLP.Config.ShowTermNature = true;//分词结果显示词性
segment.enablePartOfSpeechTagging(true);开启数字英文的合并及词性标注。
不开启这个词性标注时,只进行分词,显示出来的词性都是null。
开启之后,由于词性标注是基于词典的,永远返回词典指定的第一个词性。
2.9准确率评测
2.9.1准确率
准确率是用来衡量一个系统的准确程度的值。在不同的NLP任务中,采用不同的评价指标。
在中文分词任务中,一般使用在标准数据集上词语级别的精确率、召回率和F1值来衡量分词器的准确程度。
2.9.2混淆矩阵与TP/FN/FP/TN
混淆矩阵用来衡量分类结果的混淆程度。
TP(true positive,真阳):预测是P,答案也是P
FP(false positive,假阳):预测是P,答案是N
TN(true negative,真阴):预测是N,答案也是N
FN(false negative,假阴):预测是N,答案是F
2.9.3精确率
P =TP/(TP+FP)
精确率指的是预测结果中正类数量占全部结果的比率。
2.9.4召回率
R = TP/(TP+FN)
召回率指的是所有正类样本中,能回想起的比率。
在搜索引擎评测中,召回率为相关网页被搜索到的比例。
在区分P值和R值的时候,只需要记住两者分子都是真阳的样本数,只不过P值的分母是预测阳性的数量,R值的分母是答案阳性的数量。
2.9.5 F1值
精确率和召回率的调和平均F1值
F1 = 2PR/(P+R)
2.9.6中文分词中P,R,F1的计算
前面介绍的混淆矩阵针对的是答案和预测结果数量相等的情况,而在中文分词中,标准答案和分词结果的单词数不一定相等。
混淆矩阵针对的是分类问题,而中文分词确实一个分块问题。
因此,我们要转换思维,将分块问题转换为分类问题:
对于长为n的字符串,分词结果是一系列单词,每个单词按它在文本中的起止位置可记作区间[i,j],那么标准答案的所有区间构成一个集合A,分词结果的所有区间记作B
则:
P = A∩B/B
R = A∩B/A
2.9.7 OOV Recall Rate与IV Recall Rate
OOV指的是未登录词(out of vocabulary),或者俗称的“新词”,也即词典未收录的词汇。如何准确切分OOV,乃至识别其含义,是NLP领域的核心难题之一。
经过测评,发现OOV Recall Rate 占整个OOV的2.6%,说明词典分词对新词的召回能力几乎为零。微乎其微的2.6%来自于单字成词和HanLP实现的英文数字合并规则。
IV指的是“登录词”(in vocabulary),相应的IV Recall Rate指的是词典中的词汇被正确召回的概率,连词典中的词汇都无法百分之百召回,说明词典的消歧能力不好。
2.10字典树的其他应用
字典树除了用于中文分词外,还可以用于任何需要词典与最长匹配的任务。在HanLP中,字典树被广泛用于停用词过滤、繁简转换和拼音转换等任务。
2.10.1停用词过滤
汉语中有一类没有多少意义的词语,比如助词“的”、连词“以及”、副词“甚至”、语气词“吧”等,称为停用词。
停用词视具体任务的不同而不同,比如在网站系统中,有些敏感词汇也是停用词。因此,停用词过滤就是一个常见的预处理过程。
在停用词词典中,有很多单字词,短模式匹配,所以,可以使用DAT来构建停用词字典树。
下面是构建和使用停用词Trie,实现停用词过滤的Java实现:
构建Trie:
/**
* 从词典中加载停用词
* @param path
* @return 双数组字典树
*/
public static DoubleArrayTrie<String> loadStopwordFromFile(String path){
TreeMap<String,String> map = new TreeMap<>();
IOUtil.LineIterator lineIterator = new IOUtil.LineIterator(path);
for (String word:lineIterator)
map.put(word,word);
return new DoubleArrayTrie<String>(map);
}
/**
* 从参数构造停用词字典树
* @param words
* @return
* @throws IOException
*/
public static DoubleArrayTrie<String> loadStopwordFromWords(String... words)throws IOException{
TreeMap<String,String> map = new TreeMap<>();
for (String word:words)
map.put(word,word);
return new DoubleArrayTrie<String>(map);
}
从分词结果中移除停用词:
/**
* 从分词结果中移除停用词
* @param termList
* @param trie
*/
public static void removeStopwords(List<Term> termList, DoubleArrayTrie<String>trie){
ListIterator<Term>listIterator = termList.listIterator();
while (listIterator.hasNext())
if(trie.containsKey(listIterator.next().word))
listIterator.remove();
}
在敏感词过滤的场景下,通常需要将敏感词替换成特定的字符串。
/**
*
* @param text 母文本
* @param replacement 统一替换的字符串
* @param trie 停用词字典树
* @return
*/
public static String replaceStopwords(final String text,final String replacement,DoubleArrayTrie<String>trie){
final StringBuilder stringBuilder = new StringBuilder(text.length());
final int[] offset = new int[]{0};
trie.parseLongestText(text, new AhoCorasickDoubleArrayTrie.IHit<String>() {
@Override
public void hit(int begin, int end, String value) {
if(begin > offset[0])
stringBuilder.append(text.substring(offset[0],begin));
stringBuilder.append(replacement);
offset[0] = end;
}
});
if (offset[0] < text.length()){
stringBuilder.append(text.substring(offset[0]));
}
return stringBuilder.toString();
}
2.10.2简繁转换
简繁转换指的是简体中文和繁体中文之间的相互转换。
可能有的人觉得,这很简单,按字转换就好啦。HanLP提供了这样的朴素实现CharTable用来执行字符正规化(繁体->简体,全角->半角,大写->小写)
eg:
System.out.println(CharTable.convert("愛聽4G"));
打印结果为:爱听4G
事实上,汉字存在“一简对多繁”和“一繁对多简”的情况。
eg:“头发”、“发财”对应的繁体字为“頭髮”和“發財”,这里的"发"就是一简对多繁。
这启示我们不能按字转换,最起码是按词转换。
s:简体
t:繁体
tw:台湾繁体
hk:香港繁体
HanLP.convertToTraditionalChinese:简转繁
HanLP.s2t:简转繁
HanLP.s2tw:简转香港繁体
HanLP.s2hk:简转香港繁体
…
HanLP的繁简转换已经得到了广泛应用,甚至被一些汉化组用来简化港台繁体版的游戏。
2.10.3拼音转换
拼音转换涉及到多音字的问题,仍然需要按词转换。
2.11总结
在这一章中,我们实现了字典树、首字散列其余二分的BinTrie、DAT、AC自动机和ACDAT。基于这些高级的数据结构,我们将词典分词的速度推向了千万字每秒的新高度。不仅是分词,这些数据结构还被用于关键字过滤、繁简转换和拼音转换。
我们体会到了算法和抽象思维的力量,但是算法和数据结构仅仅是一个NLP工程师的基本功,统计思维和机器学习才是NLP的核心。
我们发现词典分词的准确率并不高,既无法区分歧义,也无法召回新词。–缺点。
标签:__,return,self,children,词典,state,._,自然语言,分词 来源: https://blog.csdn.net/lihao19990930/article/details/115572136
本站声明: 1. iCode9 技术分享网(下文简称本站)提供的所有内容,仅供技术学习、探讨和分享; 2. 关于本站的所有留言、评论、转载及引用,纯属内容发起人的个人观点,与本站观点和立场无关; 3. 关于本站的所有言论和文字,纯属内容发起人的个人观点,与本站观点和立场无关; 4. 本站文章均是网友提供,不完全保证技术分享内容的完整性、准确性、时效性、风险性和版权归属;如您发现该文章侵犯了您的权益,可联系我们第一时间进行删除; 5. 本站为非盈利性的个人网站,所有内容不会用来进行牟利,也不会利用任何形式的广告来间接获益,纯粹是为了广大技术爱好者提供技术内容和技术思想的分享性交流网站。