文本纠错
文本纠错概述
文本纠错又称为拼写检查,由于纯文本往往来源于手打或者 OCR(Optical Character Recognition,光学字符识别),很可能存在一些错误,因此此技术也是一大关键的文本预处理过程,一般可划分为两大错误类型:Non-word 拼写错误以及 Real-word 拼写错误。本实验重点介绍前者。
Non-word 拼写错误,表示此词汇本身在字典中不存在,比如把“因材施教”误写为“因才施教”,把 “correction” 误拼写为 “corrction”。这类问题的解决思路可分为两大步骤:
找到字典中与错拼词汇相近的词作为候选纠正词:例如对于错误单词 “atress”,存在多个相近候选词,比如 “actress”,“caress”,“stress”,“across”,“cress” 等。那么如何确定候选项呢?对于英文而言,通过编辑距离运算便可得到一系列相近候选词汇。而对于中文来说,一般存在两种相近模式,一是拼写相近,比如在拼音打字时出错,二则是字形相近,比如在五笔打字时出错,一般需要通过构建相近字词表查找候选词汇。
通过算法从候选项中选出最有可能的纠正选项:比如在当前例子中计算得 “actress” 为最佳纠正选项。一般情况下,有两大主要因素决定了某一候选项的可能性:“此选项在当前语境中出现的可能性”和“候选项和错误单词的相似度”,可结合两者进行综合判断。
这里值得注意的是,“选项在当前语境中出现的可能性”,其实便是“语言模型”的概念。如果是一元语言模型,只需统计单词在语料中的出现频次即可;如果是二阶或三阶语言模型,则需要统计单词在前一个或前两个单词出现的情况下出现的概率,比如计算“错误单词左边单词+候选项+错误单词右边单词”在语料中的出现情况,可以更好地综合上下文信息,来判断候选项在当前语境中出现的可能性。
Real-word 拼写错误,意思是指单词本身没有错误,但是不符合上下文语境,常常涉及语法语义层面的错误,比如把“我现在在公司里”错写成“我现在在公式里”,这类错误计算量较大,因为每个单词都是待纠错对象。通常的解决方案与第一种情况类似:
针对每个单词根据编辑距离、同音词、近形词等方式选出候选项(也包括待纠正单词本身)。
计算选项本身在当前语境中出现的可能性,以及候选项和原待纠正单词的相似度。
综合计算各候选项(也包括待纠正单词本身)是正确项的可能性,如果单词本身的概率较大,则不进行纠正,否则推荐纠正项。
注意:编辑距离(Minimum Edit Distance,MED),由俄罗斯科学家 Vladimir Levenshtein 在 1965 年提出,也因此而得名 Levenshtein Distance。通俗地来讲,编辑距离指的是在两个单词
插入(Insertion)
删除(Deletion)
替换(Substitution)
譬如,"kitten" 和 "sitting" 这两个单词,由 "kitten" 转换为 "sitting" 需要的最少单字符编辑操作有:
kitten → sitten ("k" 替换为 "s" )
sitten → sittin ("e" 替换为 "i" )
sittin → sitting (末尾添加 "g")
因此,"kitten" 和 "sitting" 这两个单词之间的编辑距离为 3 。
英文纠错实例
现有一些英文语料,存放在文件 english_data.txt 中,如何基于此语料构建一个简单的英文 Non-word 拼写纠错工具呢?根据上文中的理论我们可以这样设计:
根据编辑距离计算某个错误单词的相似单词。
由于语料库比较小(如果统计二阶或三阶语言模型,稀疏程度会很高),这里只考虑一阶语言模型,即根据语料统计每个单词的出现频次,作为选项本身在当前语境中出现的可能性。
综合以上信息推荐候选纠正项。
首先,我们构建一个函数,对某一单词进行一些编辑改动,得到新的候选单词,方法如下:
删除某一字母
相邻字母调换
替换某一字母
插入某一字母
# 英文字母,用于替换及插入操作
ALPHABET = "abcdefghijklmnopqrstuvwxyz"
def get_similar_words(word):
n = len(word)
# 在各个位置删除某一字母而得的词
s1 = [word[0:i]+word[i+1:] for i in range(n)]
# 在各个位置相邻字母调换位置
s2 = [word[0:i]+word[i+1]+word[i]+word[i+2:] for i in range(n-1)]
# 在各个位置替换
s3 = [word[0:i]+c+word[i+1:] for i in range(n) for c in ALPHABET]
# 在各个位置插入
s4 = [word[0:i]+c+word[i:] for i in range(n+1) for c in ALPHABET]
similar_words = set(s1+s2+s3+s4) # 去重
return similar_words
# 例:
get_similar_words("aand")
接下来,我们基于语料进行词频统计,首先提取语料库中的所有单词并且转化为小写。
import re
def get_words(text):
return re.findall("[a-z]+", text.lower())
根据语料中所有单词统计获取一元语言模型:
def get_unigram(words):
unigram = {}
for w in words:
if w in unigram:
unigram[w] += 1 # 增加词频
else:
unigram[w] = 1 # 初次计数为 1
return unigram
!wget -nc "https://labfile.oss.aliyuncs.com/courses/3205/english_data.txt"
UNIGRAM = get_unigram(get_words(open("english_data.txt").read()))
在案例 get_similar_words("aand") 中我们可以发现有很多变化过的词汇并非正确单词,因此需要过滤掉不正确的单词。
# 过滤非词典中的单词
def known(words):
return set(w for w in words if w in UNIGRAM)
# 例:过滤后均为正确单词
known(get_similar_words("aand"))
在上述代码中,我们完成了基于某单词的相似单词获取以及一元语言模型的统计,接下来综合两者进行纠错。
def correct(word):
if word not in UNIGRAM: # 如果单词不在词典中,说明是错误单词
candidates = known(get_similar_words(word)) # 获取相似词并过滤
if candidates:
# 在这里假设所有相似词的相似程度一样,只根据候选项频次大小作为指标进行推荐,即取频次最高的单词
candidate = max(candidates, key=lambda w: UNIGRAM[w])
print("‘{}’的推荐纠正项为‘{}’".format(word, candidate))
else:
print("‘{}’疑似错误".format(word))
else:
print("正确单词")
return
接下来进行测试:
# 例:
correct("word")
# 例:
correct("het")
# 例:
correct("kat")
在以上实例中,我们实现了一个简单的英文纠错器,事实上还有许多待改进之处,比如获取更大的英文语料构建二元或三元语言模型,对候选单词与原单词的相似度作进一步的细化,比如虽然编辑距离均为 1, ("Jane", "Jame") 的相似度明显大于 ("Jane", "Jake")。大家可以基于以上代码及思路作进一步优化。
中文纠错实例
现有一些中文单词及词频相关的语料,存放在文件 chinese_words.txt 中,内容格式如下(第一列为单词,第二列为词频,第三列为词性):
一万天 2 m
一万头 2 m
一万宗 2 m
一万家 8 m
根据上文中的理论解释,我们知道中文的错误类型比较多,比如形似字错误、近音词错误、语义错误等等,我们这里只针对近音词相关错误构建一个中文 Non-word 拼写纠错工具,流程如下:
基于语料构建中文词典以及拼音与单词的对应词典。
基于输入的中文文本找出错误单词。
根据某一单词的拼音获取其同音词以及近音词候选项。
基于候选项的相似程度以及词频信息推荐纠正项。
首先,我们基于语料获取中文词表以及拼音与单词的对应词典(由于在后续操作中,会有基于拼音获取单词的操作)。
安装单词拼音转换工具包 pypinyin:
!pip install pypinyin
from pypinyin import pinyin, Style
# 获取中文词表以及拼音单词词典(中文单词拼音作为 key,(单词,词频)作为 value)
def construct_help_data(file_path):
pinyin_wordfreq = {} # 中文词表
chinese_words = [] # 拼音单词词典
with open(file_path, "r", encoding='utf-8') as f:
for line in f:
content = line.split() # 将每一行进行切分
word, freq = content[0], content[1] # 分别取单词及其词频
chinese_words.append(word) # 加入中文词频
# 将拼音转化为 tuple,才能作为 key
word_pinyin = tuple([i[0]
for i in pinyin(word, style=Style.TONE3)])
# 加入拼音单词词典
if word_pinyin in pinyin_wordfreq:
pinyin_wordfreq[word_pinyin].append((word, freq))
else:
pinyin_wordfreq[word_pinyin] = [(word, freq)]
return chinese_words, pinyin_wordfreq
!wget -nc "https://labfile.oss.aliyuncs.com/courses/3205/chinese_words.txt"
CHINESE_WORDS, PINYIN_WORDFREQ = construct_help_data("chinese_words.txt")
中文词表如下:
CHINESE_WORDS
拼音单词词典如下:
PINYIN_WORDFREQ
接下来,基于输入的中文文本找出其中的错误单词,只需要判断其是否有词表中出现过即可。
import jieba
# 找出文本中的错误中文单词
def find_error(text):
errors = []
text_cuted = jieba.lcut(text) # 分词
for word in text_cuted:
if not re.search(r'[^\u4e00-\u9fa5]', word): # 只保留全为中文的单词
if word not in set(CHINESE_WORDS): # 如果单词不在词典中,则为错误单词
errors.append(word)
return errors
# 例:
find_error("小朋友商心了")
注:以上 find_error() 函数为了方便大家理解,写得比较通俗但冗长,个人倾向于以下精简的写法,且之后的代码均以精简形式出现。同学们也可以试着在练习中把精简版本还原成通俗版本,以加强代码的理解。
# 精简版本
def find_error(text):
return [word for word in jieba.lcut(text) if not re.search(r'[^\u4e00-\u9fa5]', word) and word not in set(CHINESE_WORDS)]
# 例:
find_error("小朋友商心了")
接下来,即基于拼音获取同音词以及近音词候选项,同时还包括这些项的相似程度及频次信息,这也是此案例中的核心步骤,主要包括以下两大函数:
get_same_pinyin_words():基于某单词获取同音词。
get_similar_pinyin_words():基于某单词获取近音词。
要实现基于某一单词获取同音词或者近音词,两者共同的步骤中都包含“把单词转化为拼音”,“根据拼音查找相关单词”,因此将其封装为公用函数:
get_pinyin_by_word():基于单词获取拼音。
get_wordfreq_by_pinyins():根据拼音获取词语及频次信息。
# 基于单词获取拼音
def get_pinyin_by_word(word):
return [i[0] for i in pinyin(word, style=Style.TONE3)] # 获取拼音组成的 list
# 例:
get_pinyin_by_word("开心")
# 根据拼音获取词语及频次,并且根据频次排序并返回
def get_wordfreq_by_pinyins(pinyins):
pinyin_words = []
for p in pinyins: # 注意,输入的 pinyins 为 list 形式,因为相近拼音可能有多个
pinyin_words = pinyin_words + \
PINYIN_WORDFREQ.get(p, []) # 获取拼音对应的单词及词频
if pinyin_words:
pinyin_words = sorted(pinyin_words, key=lambda x: int(
x[1]), reverse=True) # 基于词频排序
return pinyin_words
# 例:
get_wordfreq_by_pinyins([("qi3", "shi4")])
# 基于某一单词获取同音词
def get_same_pinyin_words(word):
pinyin_original = tuple(get_pinyin_by_word(word)) # 获取原拼音
# 获取与原拼音一样的单词作为候选项
same_pinyin_words = get_wordfreq_by_pinyins([pinyin_original])
return same_pinyin_words
# 例:
get_same_pinyin_words("启是")
对于函数 get_similar_pinyin_words(),即基于某单词获取近音词,流程稍显复杂,主要逻辑如下:
基于待纠正单词获取拼音,如‘定轻’转化为(‘ding4’,‘qing1’)。
根据某个字声调相差一位,确定相似拼音,如(‘ding4’,‘qing2’);根据易混淆前后鼻音,确定相似拼音,如(‘ding4’,‘qin1’);根据易混淆声母,确定相似拼音,如(‘ting4’,‘qing2’)。并且,对于不同的相差类型进行相似度分级,这里默认前两类的相似度大于后者。
基于相似拼音获取相似单词及词频。
对于以上流程中的第二步,单独封装为一个函数 get_similar_pinyins(),即根据拼音相近获取不同类型的相近音。
import re
# 输入单词,根据其拼音获取相近音
def get_similar_pinyins(word):
pinyin_original = get_pinyin_by_word(word) # 获取原拼音
first_order_candidate_pinyin = [] # 基于声调以及前后鼻音的相近拼音(一级相似度)
second_order_candidate_pinyin = [] # 基于声母的相近拼音(二级相似度)
# 根据某个字声调相差一位,确定相似拼音
for i, p in enumerate(pinyin_original):
if re.search(r"\d$", p): # 如果以数字结尾,说明有声调
all_candidate_tones = [re.sub(r"\d$", tone, p) for tone in [
"1", "2", "3", "4", ""]] # 对声调进行替换,“”表示第 5 声调
else: # 如果不以数字结尾,说明是第5声调
all_candidate_tones = [
p+tone for tone in ["1", "2", "3", "4", ""]] # 在拼音末尾添加不同声调
# 变换当前字的声调与其它字的原拼音进行组合,此处需注意 first_order_candidate_pinyin 也包含了原拼音
first_order_candidate_pinyin += [tuple(pinyin_original[0:i] + [candidate_tone]
+ pinyin_original[i+1:])
for candidate_tone in all_candidate_tones]
# 去除原拼音
if tuple(pinyin_original) in first_order_candidate_pinyin:
first_order_candidate_pinyin.remove(tuple(pinyin_original))
# 根据易混淆前后鼻音,确定相似拼音
nasal_confused = [("en", "eng"), ("eng", "en"),
("in", "ing"), ("ing", "in")] # 易混淆的前后鼻音对
nasal_expressions = [re.compile(r'({})(\d?)$'.format(
s[0])) for s in nasal_confused] # 查找鼻音的正则表达式
for i, p in enumerate(pinyin_original): # 遍历每个字的拼音
for exp, s in zip(nasal_expressions, nasal_confused):
if re.search(exp, p): # 如果在某个字的拼音中查找到易混淆的前后鼻音,进行替换
candidate_nasal_confused = re.sub(
exp, s[1]+"\\2", p) # 根据易混淆的前后鼻音对进行替换
# 变换过的当前字拼音与其它字的原拼音进行组合,并加入一级相似度候选项中
first_order_candidate_pinyin.append(
tuple(pinyin_original[0:i]+[candidate_nasal_confused]+pinyin_original[i+1:]))
# 一个字中只可能存在一个鼻音,因此如果找到了循环就结束,进入下一个字
break
# 根据易混淆声母,确定相似拼音
initial_confused = [("d", "t"), ("t", "d"), ("b", "p"), ("p", "b"),
("l", "n"), ("n", "l"), ("h", "f"), ("f", "h")] # 易混淆的声母对
initial_expressions = [re.compile(r'^{}'.format(s[0]))
for s in initial_confused] # 查找易混淆的声母的正则表达式
for i, p in enumerate(pinyin_original): # 遍历每个字的拼音
for exp, s in zip(initial_expressions, initial_confused):
if re.search(exp, p): # 如果在某个字的拼音中查找到易混淆的声母,进行替换
candidate_initial_confused = re.sub(
exp, s[1], p) # 根据易混淆的声母对进行替换
# 变换过的当前字拼音与其它字的原拼音进行组合,并加入二级相似度候选项中
second_order_candidate_pinyin.append(
tuple(pinyin_original[0:i]+[candidate_initial_confused]+pinyin_original[i+1:]))
# 一个字中只可能存在一个声母,因此如果找到了循环就结束,进入下一个字
break
return first_order_candidate_pinyin, second_order_candidate_pinyin
# 例 1
first_order_candidate_pinyin, second_order_candidate_pinyin = get_similar_pinyins(
"肥家")
print("基于声调以及前后鼻音的相近拼音:\n", first_order_candidate_pinyin)
print("基于声母的相近拼音:\n", second_order_candidate_pinyin)
# 例 2
first_order_candidate_pinyin, second_order_candidate_pinyin = get_similar_pinyins(
"商心")
print("基于声调以及前后鼻音的相近拼音:\n", first_order_candidate_pinyin)
print("基于声母的相近拼音:\n", second_order_candidate_pinyin)
# 基于某一单词根据相近音获取近音词
def get_similar_pinyin_words(word):
pinyin_original = tuple(get_pinyin_by_word(word)) # 获取原拼音
first_order_candidate_pinyin, second_order_candidate_pinyin = get_similar_pinyins(
word) # 获取相近音
# 一级近音词
first_order_similar_words = get_wordfreq_by_pinyins(
first_order_candidate_pinyin)
# 二级近音词
second_order_similar_words = get_wordfreq_by_pinyins(
second_order_candidate_pinyin)
return first_order_similar_words, second_order_similar_words
# 例:
first_order_similar_words, second_order_similar_words = get_similar_pinyin_words(
"启是")
print("一级近音词:\n", first_order_similar_words)
print("二级近音词:\n", second_order_similar_words)
在以上过程中,我们完成了错误查找、同音词以及近音词查找以及相关频次信息的获取,接下来,将以上功能进行组合便可完成中文 Non-word 错误基于拼音的纠错。
# 中文纠错主函数
def correct(text):
errors = find_error(text)
print(errors)
if not errors:
print("无错误")
else:
for error in errors:
top_order_similar_words = get_same_pinyin_words(error)
first_order_similar_words, second_order_similar_words = get_similar_pinyin_words(
error)
# 默认为相似程度:同音词 > 一组级相似度(声调变化及鼻音变化)
# > 二级相似度(声母变化),而同一组中的相似度由词频决定
candidates = top_order_similar_words + \
first_order_similar_words + second_order_similar_words
if candidates:
print("‘{}’的推荐纠正项为‘{}’".format(
error, candidates[0][0])) # 取候选项中的首位
else:
print("‘{}’疑似错误".format(error))
return
# 例 1
correct("小盆友商心了")
# 例 2
correct("天气很不错,空气青新")
至此为止,我们实现了一个简单的中文纠错器,事实上还有许多待改进之处,比如同英文一样,也获取更多的中文文本语料构建二元或三元语言模型,对候选单词与原单词的拼音相似度作进一步的调研评估,比如对于某些地区,h 和 f 或者 l 和 n 相比于前后鼻音更易混淆。大家可以基于以上代码及思路作进一步优化。