介绍
这篇文章主要对pycorrector默认使用规则的代码进行debug和理解,不论怎样,应先读下作者的readme,有个充分的理解先。
初始化工作
初始化主要做了三件事:
初始化一些词典,用于后面纠错用。 加载kenlm模型。 初始化jieba分词器。
1. 初始化一些词典等
1 2 3 4 5 6 7 8 9 10
check_corrector_initialized() def _initialize_corrector (self) : self.cn_char_set = self.load_set_file(self.common_char_path) self.same_pinyin = self.load_same_pinyin(self.same_pinyin_text_path) self.same_stroke = self.load_same_stroke(self.same_stroke_text_path) self.initialized_corrector = True
1 2
text = convert_to_unicode(text)
额外插句:单从re_han
这里就可以看出,作者至少对jieba很熟悉。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
def split_2_short_text (text, include_symbol=False) : """ 长句切分为短句 :param text: str :param include_symbol: bool :return: (sentence, idx) """ result = [] blocks = re_han.split(text) start_idx = 0 for blk in blocks: if not blk: continue if include_symbol: result.append((blk, start_idx)) else : if re_han.match(blk): result.append((blk, start_idx)) start_idx += len(blk) return result
2. 加载kenlm模型
关于kenlm,网上搜了下,除了纠错基本很少有人用到(而且还是针对pycorrector~),只有这篇文章 说的还有点意思,而且看Github kenlm 介绍,作者也是十分任性,只强调速度,没有强调用处。。。
简单来讲,kenlm是基于n-gram训练出来的一个预训练模型,它的更多用法可看Example 。
3. 初始化jieba
对于人名和place这种词典,不如使用现成了命名实体模型,这种词典的方式总之是无法完全枚举的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
self.word_freq = self.load_word_freq_dict(self.word_freq_path) self.custom_confusion = self._get_custom_confusion_dict(self.custom_confusion_path) self.custom_word_freq = self.load_word_freq_dict(self.custom_word_freq_path) self.person_names = self.load_word_freq_dict(self.person_name_path) self.place_names = self.load_word_freq_dict(self.place_name_path) self.stopwords = self.load_word_freq_dict(self.stopwords_path) self.custom_word_freq.update(self.person_names) self.custom_word_freq.update(self.place_names) self.custom_word_freq.update(self.stopwords) self.word_freq.update(self.custom_word_freq) self.tokenizer = Tokenizer(dict_path=self.word_freq_path, custom_word_freq_dict=self.custom_word_freq, custom_confusion_dict=self.custom_confusion)
错字识别
1. 基于word级别的错字识别
这部分使用jieba的search模式进行分词。
它的实现原理是:先使用hmm进行分词,比如少先队员因该为老人让坐
,它的分词结果是["少先队员", "因该", "为", "老人", "让", "坐"]
,然后对每个词再用2阶gram和3阶gram进行切分,在self.FREQ
中进行查找是否存在,得到的结果如下:
1 2 3 4 5 6 7 8
('队员' , 2 , 4 ) ('少先队' , 0 , 3 ) ('少先队员' , 0 , 4 ) ('因该' , 4 , 6 ) ('为' , 6 , 7 ) ('老人' , 7 , 9 ) ('让' , 9 , 10 ) ('坐' , 10 , 11 )
分完词后,按词粒度判断是否在词典里,符号,英文则跳过,否则则认为是可能错的。
到这里识别出因该
是可能错误的。
2. 基于kenlm级别的错字识别
取bigram和trigram,通过kenlm获取对应的score,然后求平均获取和句子长度一致的score。
比如:
1
sent_scores = [-5.629326581954956 , -6.566553155581156 , -6.908517241477966 , -7.255491574605306 , -7.401519060134888 , -7.489806890487671 , -7.1438290278116865 , -6.559153278668722 , -6.858733296394348 , -7.7903218269348145 , -8.28114366531372 ]
然后通过这个sent_scores
取判断哪些index是错的。
那作者是怎么判断的呢?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
def _get_maybe_error_index (scores, ratio=0.6745 , threshold=2 ) : """ 取疑似错字的位置,通过平均绝对离差(MAD) :param scores: np.array :param ratio: 正态分布表参数 :param threshold: 阈值越小,得到疑似错别字越多 :return: 全部疑似错误字的index: list """ result = [] scores = np.array(scores) if len(scores.shape) == 1 : scores = scores[:, None ] median = np.median(scores, axis=0 ) margin_median = np.abs(scores - median).flatten() med_abs_deviation = np.median(margin_median) if med_abs_deviation == 0 : return result y_score = ratio * margin_median / med_abs_deviation scores = scores.flatten() maybe_error_indices = np.where((y_score > threshold) & (scores < median)) result = [int(i) for i in maybe_error_indices[0 ]] return result
按照百度百科平均绝对离差 的定义:平均绝对离差定义为各数据与平均值的离差的绝对值的平均数
,那作者这里的计算方式貌似就不一样了。 作者这里的计算方式不是求平均值,而是每个值减去中位数,然后再求中位数,这样做的好处更多是防止数据分布比较大,就比如大家的平均工资都很高~ 作者接着使用两个比较,(1)ratio * np.abs(score - median) / 平均绝对离差 (2)scores 小于 中位数的,这地方看的迷迷糊糊,总有种凭经验的感觉。 获取对应的错字index。
至此获取到的可能错误列表是:
1
[['因该' , 4 , 6 , 'word' ], ['坐' , 10 , 11 , 'char' ]]
纠错
1. 获取纠错候选集
假设当前输入word是因该
:
获取相同拼音的(不包含声调) _confusion_word_set
自定义混淆集 _confusion_custom_set
他这个获取相同拼音的写法就让我觉得emo,直接在self.known(自定义词典)里找长度相同,然后判断拼音一样不就得了~
自定义混淆集就是自定义一些经验进行。比如{“因该”: “应该”}这种,增大候选集。
这地方分成三部分:
如果word的长度等于1。获取相同拼音的same pinyin 加载同音的列表
,以及加载形似字same stroke 加载形似字
。 如果word的长度等于2。截取第一个字符,如因
,然后获取相同拼音的same pinyin 加载同音的列表
,以及加载形似字same stroke 加载形似字
,然后和该
进行拼接,获取新的候选集。第二个字该
执行相同操作。 如果word的长度大于2。同理上述操作,只不过粒度不同(此处忽略)。
三、对候选集进行排序,以word_freq进行排序,然后只截取前K个候选集
2. 从候选集里面进行筛选
这个地方就有意思了,如何获取最正确的那个呢?看下面代码。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
def get_lm_correct_item (self, cur_item, candidates, before_sent, after_sent, threshold=57 , cut_type='char' ) : """ 通过语言模型纠正字词错误 :param cur_item: 当前词 :param candidates: 候选词 :param before_sent: 前半部分句子 :param after_sent: 后半部分句子 :param threshold: ppl阈值, 原始字词替换后大于该ppl值则认为是错误 :param cut_type: 切词方式, 字粒度 :return: str, correct item, 正确的字词 """ result = cur_item if cur_item not in candidates: candidates.append(cur_item) ppl_scores = {i: self.ppl_score(segment(before_sent + i + after_sent, cut_type=cut_type)) for i in candidates} sorted_ppl_scores = sorted(ppl_scores.items(), key=lambda d: d[1 ]) top_items = [] top_score = 0.0 for i, v in enumerate(sorted_ppl_scores): v_word = v[0 ] v_score = v[1 ] if i == 0 : top_score = v_score top_items.append(v_word) elif v_score < top_score + threshold: top_items.append(v_word) else : break if cur_item not in top_items: result = top_items[0 ] return result
核心的地方在self.ppl_score
那里,代码如下:
1 2 3 4 5 6 7 8 9 10 11 12
def ppl_score (self, words) : """ words比如:['少', '先', '队', '员', '应', '该', '为', '老', '人', '让', '坐'] 取语言模型困惑度得分,越小句子越通顺 :param words: list, 以词或字切分 :return: """ self.check_detector_initialized() return self.lm.perplexity(' ' .join(words))
看作者注释,说的很明白了,如果这个句子越是流畅的,那么他的score就会更高。
1 2 3 4 5 6 7 8 9
pprint(sorted_ppl_scores) [('应该' , 144.39704182754554 ), ('因改' , 236.80615502078768 ), ('因该' , 284.14769660593794 ), ('听该' , 357.8835799332408 ), ('因盖' , 360.68106481988417 ), ('因核' , 365.9438178618582 ), ]
最后一步,作者以score最高的那个加了一个threshold,如果得分在这个阈值内的,添加到候选的top_items里面。 如果当前的cur_item,即因该
不在这个候选集里,那么取第一个top_items
,如果在,那么就返回当前的cur_item。
这步的目的在于防止误判。
思考
对于时间日期、人名这种,个人感觉应该先用命名实体剔除掉。 自定义词典、形近词那里会是个问题,比如量少或者有歧义怎么解决,另外因该
也是有可能作为一个单独的词,只是出现的可能性较小。 默认加载的是5-gram,关于这里为什么用5-gram没细研究。 关于字粒度纠错,那里我感觉真统计。。。 不过我喜欢纠错那里,方式简单直接。不过候选集那里可能会是个瓶颈。