代码 Repo:https://github.com/OrangeX4/simple-pinyin
git clone https://github.com/OrangeX4/simple-pinyin.git
首先要安装依赖:
# python
cd pinyin
pip install -r requirements.txt
# web
cd web
npm install
简易拼音输入法提供了两种 UI,分别是命令行和前端模式。
首先是 命令行模式:
cd pinyin
python ./__init__.py
然后输入 cli
就能进入命令行模式的拼音输入法了。
其次是 前端模式:
cd pinyin
python ./__init__.py
然后输入 server
就能启动一个服务器后端。
再输入
cd web
npm run start
就能启动一个前端界面了:
当然你也可以选择 在 Python 中导入该输入法 并使用:
from pinyin.ime import ime
print(ime('jintian', limit=1)) # 基础功能
print(ime('jintain', limit=1)) # 纠错功能
print(ime('ji\'ntian', limit=1)) # 分词功能
print(ime('jintiantianqibucuo', limit=1)) # 短句功能
print(ime('jttqbc', limit=1)) # 首字母功能
print(ime('xiaolian', limit=1)) # emoji 功能
print(ime('nanjingdaxuerengongzhinengxueyuan', limit=1)) # 南京大学人工智能学院
print(ime('nanjingdx', limit=1)) # 南京大学
然后终端会输出
[(('jin', 'tian'), '今天', -8.551883029536198)]
[(('jin', 'tian'), '今天', -8.551883029536198)]
[(('ji', 'ni', 'tan'), '记念堂', -23.44058007221551)]
[(('jin', 'tian', 'tian', 'qi', 'bu', 'cuo'), '今天天气不错', -25.909553919977228)]
[(('j', 't', 't', 'q', 'b', 'c'), '今天天气不错', -28.974507662573167)]
[(('xiao', 'lian'), '笑脸', -11.639523579866978), (('xiao', 'lian'), '😄', -11.639533579866978)]
[(('nan', 'jing', 'da', 'xve', 'ren', 'gong', 'zhi', 'neng', 'xve', 'yuan'), '南京大学人工智能学院', -53.58047344465382)]
[(('nan', 'jing', 'd', 'x'), '南京大学', -17.561359659026092)]
Python 的项目结构为:
.
├── __init__.py
├── cli # 命令行模式
│ ├── __init__.py
├── cut # 拼音划分代码
│ ├── __init__.py
│ └── cut_pinyin.py
├── data # 数据保存位置
│ ├── ReadMe.txt.txt
│ ├── all_pinyin.txt
│ ├── checkpoints
│ │ ├── hmm_emission_counter.json
│ │ ├── hmm_start_counter.json
│ │ └── hmm_transition_counter.json
│ ├── emoji.json
│ ├── emoji.txt
│ ├── global_wordfreq.release.txt
│ ├── hmm_emission.json
│ ├── hmm_reversed_emission.json
│ ├── hmm_reversed_transition.json
│ ├── hmm_start.json
│ ├── hmm_transition.json
│ ├── intact_pinyin.txt
│ └── test_words.txt
├── hmm # 隐马尔可夫模型
│ ├── __init__.py
│ └── viterbi.py
├── ime # 输入法函数
│ ├── __init__.py
│ ├── emoji.py # emoji 处理及加载
│ └── ime.py
├── requirements.txt
├── server # 服务器
│ ├── __init__.py
├── tests # 单元测试
│ ├── __init__.py
│ ├── test_cut_pinyin.py
│ └── test_viterbi.py
└── train # 隐马尔可夫模型训练
├── __init__.py
├── dataset.py # 加载数据
└── train_hmm.py # 具体训练代码
以下是具体实现过程的介绍。
首先要解决的问题是,如何对长拼音序列进行划分。
由于我们只有文本语料,没有输入法语料(即用户的输入习惯),我们只能够通过编写规则的方式进行拼音划分。
- 普通情况:完整的、无错的、没有歧义的拼音序列,例如
kongqi
只能划分为kong'qi
,即「空气」,并且是完整的、无错的、没有歧义的。这种情况处理起来比较简单,只需要按照「声母」和「韵母」的简单划分和匹配即可。 - 拼音简写:用户在输入的时候,往往不会输入完整的拼音,而是输入一部分拼音。而缩写的情况又有几种类别,由于没有输入法语料,我就按照我自己使用的简写方式频率排列,以「中国」举例如下:
- 先整后简:
zhong'g
,也即「去尾字母完整划分」,我们在输入一个词语的时候,常常会输入了前一个字的完整拼音,又输入后一个字的开头拼音,输入法就会匹配到对应的词语,不需要输入完整的拼音。 - 完全简写:
z'g
,我们只输入词语的拼音首字母,也是比较常见的情况。 - 先简后整:
z'guo
,比较少见,一般出现在想要使用「完全简写」的方式输入,但是发现匹配不到,因此再输入后一个字的 - 部分简写:
zh'g
,不太常见,但是也存在这种情况。
- 先整后简:
- 顺序错误:用户在输入拼音的时候,可能会因为打字打得比较快,有一些字的拼音的顺序弄反了,例如「小路」的
xiao'lu
打成了xaio'lu
,这时候输入法应该给予纠正。 - 存在歧义:例如
xianmianguan
既可以划分为xian'mian'guan
,即「鲜面馆」,也可以划分为xi'an'mian'guan
,即「西安面馆」,这时候拼音划分就存在着歧义。如果涉及到拼音简写,则歧义会更多,如zhongguo
甚至可以划分为z'hong'gu'o
。
我们需要将拼音划分分为两个不同的场景,不同场景的应用不同。第一个场景是「用户输入拼音序列划分」,第二个场景是「文字转拼音后划分」,前者用于预测,后者用于训练。
用户输入拼音序列划分只需要使用简单的动态规划即可实现,将所有合法的拼音序列划分方式都给列举出来,然后同时进行预测。
首先是输入拼音序列的划分,可以通过 from pinyin.cut import cut_pinyin
引入,具体的实现代码如下,使用了简单的动态规划,并加入了使用 '
分词的功能:
# 加载完整拼音对应的拼音表 data/intact_pinyin.txt, 共 416 个
intact_pinyin_set = set()
with open('data/intact_pinyin.txt', 'r', encoding='utf-8') as f:
intact_pinyin_set = set(s for s in f.read().split('\n'))
# 生成带残缺部分的拼音, 例如 'ruan' 对应的 'r', 'ru' 和 'rua', 共 504 个, 对应的拼音表为 data/all_pinyin.txt
all_pinyin_set = set(s[:i] for s in intact_pinyin_set for i in range(1, len(s) + 1))
# 用于保存动态规划答案的字典
intact_cut_pinyin_ans = {}
all_cut_pinyin_ans = {}
# 动态规划判断进行拼音划分
def cut_pinyin(pinyin: str, is_intact=False, is_break=True):
'''
进行拼音划分, 返回拼音划分结果列表
pinyin: 待划分的拼音, 并且是无空格字符串, 例如 `kongjian`
is_intact: 拼音是否需要完整匹配, 默认为 False, 可以使用残缺部分的拼音进行分词
is_break: 是否开启分隔符, 开启后可以使用 ' 进行分割, 例如 `kong'jian`
return: 拼音划分结果列表, 例如 `cut_pinyin('kongjian', True)`,
会返回 `[('kong', 'jian'), ('kong', 'ji', 'an')]`
'''
if is_intact:
pinyin_set = intact_pinyin_set
ans_dict = intact_cut_pinyin_ans
else:
pinyin_set = all_pinyin_set
ans_dict = all_cut_pinyin_ans
# 如果保存有, 直接返回保存结果
if pinyin in ans_dict:
return ans_dict[pinyin]
# 如果 is_break, 就进行分割
if is_break and '\'' in pinyin:
pinyins = pinyin.split('\'')
components = [cut_pinyin(p, is_intact, False) for p in pinyins]
ans = components[0]
for i in range(1, len(components)):
ans = [p1 + p2 for p1 in ans for p2 in components[i]]
return ans
# 如果没有, 递归地动态规划生成
ans = [] if pinyin not in pinyin_set else [(pinyin,)]
for i in range(1, len(pinyin)):
# 进行划分 pinyin[:i], 如果是正确拼音, 就继续动态规划
if pinyin[:i] in pinyin_set:
appendices = cut_pinyin(pinyin[i:], is_intact, is_break=False)
for appendix in appendices:
ans.append((pinyin[:i],) + appendix)
ans_dict[pinyin] = ans
return ans
在 cut_pinyin
函数的基础上,我们可以加入拼音纠错功能,从第二个字母开始依次交换连续的两个字母,看看是否能够进行完整拼音划分,能的话就加入最终结果,进而实现纠错功能。
def cut_pinyin_with_error_correction(pinyin: str):
'''
纠错匹配, 从第二个字母开始, 依次交换两个连续字母并进行*完整划分*.
如果完整划分返回非空列表, 即匹配成功, 并加入到返回字典中.
pinyin: 待纠错划分的拼音
return: 返回字典, 字典的 key 为纠错后的拼音序列, value 为匹配成功的划分结果.
并且会包含一个 key = 'all' 的项, 包括了所有 value.
'''
ans = {}
for i in range(1, len(pinyin) - 1):
# 避免交换分词符
if pinyin[i-1] == '\'' or pinyin[i] == '\'' or pinyin[i + 1] == '\'':
continue
key = pinyin[:i] + pinyin[i + 1] + pinyin[i] + pinyin[i + 2:]
value = cut_pinyin(key, is_intact=True)
if value:
ans[key] = value
ans['all'] = [p for t in ans.values() for p in t]
return ans
最后加入去尾字母划分功能,最后聚合一下,就实现了全部的输入拼音序列划分功能。
def cut_pinyin_with_strategy(pinyin: str):
'''
使用各种策略对拼音进行划分, 其中包括:
1. 完整划分
2. 去尾字母完整划分
3. 纠错划分
4. 去尾字母纠错划分
5. 模糊划分
6. 结果综合
pinyin: 待划分的拼音
'''
ans = {
'intact': cut_pinyin(pinyin, is_intact=True),
'intact_tail': [] if pinyin[-1] not in all_pinyin_set else [t + (pinyin[-1],) for t in cut_pinyin(pinyin[:-1], is_intact=True)],
'error_correction': cut_pinyin_with_error_correction(pinyin)['all'],
'error_correction_tail': [] if pinyin[-1] not in all_pinyin_set else [t + (pinyin[-1],) for t in cut_pinyin_with_error_correction(pinyin[:-1])['all']],
'fuzzy': cut_pinyin(pinyin, is_intact=False),
'combine': [],
}
ans['combine'] = set(ans['intact'] + ans['intact_tail'] + ans['error_correction'] + ans['error_correction_tail'] + ans['fuzzy'])
return ans
顺带一提,很多人误认为「略」的拼音是 lue
,实际上应该是 lve
,因此我们需要对拼音进行规范化:
def normlize_pinyin(pinyin: str):
"""
规范化拼音
将所有 ue 转化为 ve
"""
return pinyin.replace('ue', 've', -1)
其次是文字转拼音后划分, 这时候拼音划分是已知的, 所以只需进行简写处理, 然后给不同简化方式 划分权重 即可. 这一步需要在生成发射矩阵的时候设置.
这里我们直接使用了「北京语言大学 BCC 语料库 http://bcc.blcu.edu.cn」的词频语料 global_wordfreq.release.txt
,最终解释权归北语大数据与教育技术研究所所有。
其中的语料格式大致为:
第 2002074595
的 943370349
了 255733044
在 197672850
是 171296602
我 169391220
~ 44057380
非常 8056541
一直 8013106
不会 8010572
应该 8001472
即「词语 + 词频」的组合,不过注意到有一些非中文的词语,例如 ~ 44057380
,因此我们需要进行过滤,最后再使用 Python 的生成器功能,我们就能解耦合地进行数据的读入,具体代码位于 train/dataset.py
。
def is_Chinese(word):
'''
判断一个字符串是否全由汉字组成, 用于过滤文本
'''
return all('\u4e00' <= ch <= '\u9fff' for ch in word)
def iter_word_and_freq():
"""
词频数据集, 迭代地返回 (word, freq)
"""
with open(words_path, 'r', encoding='utf-8') as f:
for line in f:
try:
word, frequency = line.split()
# 进行过滤
if is_Chinese(word):
yield word, int(frequency)
except Exception as e:
pass
隐马尔可夫模型 (Hidden Markov Model, HMM) 是一种统计模型,用来描述一个含有隐含未知参数的马尔可夫过程。
隐马尔可夫模型有两个关键的概念:状态 (state) 和 观测 (observation)。隐马尔可夫链随机生成的状态的序列,称为状态序列;每个状态生成一个观测,由此产生的随机的观测的序列,称为观测序列。序列的每一个位置又可以看作一个时刻。
对于拼音输入法来说,状态就是一个个汉字,观测就是对应的拼音。作为状态的汉字是不知道的,唯一知道的只有用户输入的观测,也就是拼音。
隐马尔可夫模型由初始状态概率向量
由定义可知,隐马尔可夫模型有两个重要的基本假设:
-
齐次马尔可夫性假设:隐马尔可夫链在任意时刻
$t$ 的状态只依赖于前一时刻$t-1$ 的状态,与其他时刻的状态及观测无关,也与时刻$t$ 的数值无关。 -
观测独立性假设:任意时刻的观测只与该时刻的状态有关,与其他观测及状态无关,也与时刻
$t$ 的数值无关。
要使用隐马尔可夫模型来实现智能拼音输入法,我们 首先要通过语料生成对应的隐马尔可夫模型。
要生成隐马尔可夫模型也十分简单,根据 极大似然估计法 的结果,我们只需要用 频率 代替 概率 即可,也就是要统计语料中的频率,从而生成
对应的统计频率的代码相对简单,这里就不过多赘述,需要看的话可以看看 train/train_hmm.py
这个文件的代码。
不过值得一提的是,由于状态转移矩阵 data/hmm_xxx.json
.
另外,由于后续概率计算数字可能越算越小,导致计算机无法计算,所以我们对所有概率都进行了自然对数运算处理。
生成的 hmm_start.json
的部分内容:
{
"一": -5.081293678906249,
"丁": -9.192433659783104,
"丂": -19.34085746030175,
"七": -10.159009715890134,
"丄": -16.747217610377284,
"丅": -16.301496324358553,
"丆": -19.25047339883348,
"万": -8.7175005342102
}
生成的 hmm_transition.json
的部分内容:
{
"渗": {
"入": -2.439070674759006,
"出": -1.9373713062580147,
"性": -4.919727895519666,
"析": -5.954770052141584,
"水": -3.190476888777123,
"流": -3.756776835259451,
"漏": -2.4294073791727087,
"透": -0.5143045447650618,
},
"渚": {
"乡": -4.32027991176323,
"停": -6.5756121199067294,
"公": -4.33654043263501,
"山": -4.052965142135882,
"文": -0.50469675623453,
"村": -4.877881600326951,
"港": -2.8828938894856275,
"湖": -2.9667753734663296,
"镇": -1.7247845019528727,
}
}
生成的 hmm_emission.json
的部分内容:
{
"一": {
"y": -0.9808292530117262,
"yi": -0.4700036292457356
},
"模": {
"m": -0.9808292530117262,
"mo": -0.5430199262500778,
"mu": -3.12336226291328
}
}
训练完隐马尔可夫模型后,我们就要进行预测了。
隐马尔可夫模型的预测问题,也称为解码 (decoding) 问题,就是在已知隐马尔可夫模型
这里我们使用维特比算法 (Viterbi algorithm) 来进行预测。
为了加速维特比算法, 我们要先通过倒查表的方式计算出 reversed_emission_matrix
和 reversed_transition_matrix
.
def gen_reversed_matrix(emission_matrix, transition_matrix):
'''
生成 emission_matrix 的倒查表, 即 reversed_emission_matrix[拼音] = {汉字: 概率}
生成 transition_matrix 和 emission_matrix 的联合倒查表,
即 reversed_transition_matrix[前一个汉字][拼音] = (后一个汉字, 最大概率)
'''
# 生成 emission_matrix 的倒查表, 即 reversed_emission_matrix[拼音] = {汉字: 概率}
reversed_emission_matrix = {}
for char in tqdm(emission_matrix):
for pinyin, prob in emission_matrix[char].items():
if pinyin not in reversed_emission_matrix:
reversed_emission_matrix[pinyin] = {}
reversed_emission_matrix[pinyin][char] = prob
json2file(reversed_emission_matrix, hmm_reversed_emission_path)
# 生成 transition_matrix 和 emission_matrix 的联合倒查表,
# 即 reversed_transition_matrix[前一个汉字][拼音] = (后一个汉字, 最大概率)
reversed_transition_matrix = {}
for previous in tqdm(transition_matrix):
reversed_transition_matrix[previous] = {}
for behind in transition_matrix[previous]:
for pinyin in emission_matrix[behind]:
prob = transition_matrix[previous][behind] + emission_matrix[behind][pinyin]
if pinyin not in reversed_transition_matrix[previous]:
reversed_transition_matrix[previous][pinyin] = (behind, prob)
elif prob > reversed_transition_matrix[previous][pinyin][1]:
reversed_transition_matrix[previous][pinyin] = (behind, prob)
json2file(reversed_transition_matrix, hmm_reversed_transition_path)
然后是维特比算法的具体代码:
def viterbi(pinyin, limit=10):
"""
viterbi 算法
pinyin: 拼音元组, 例如 ('jin', 'tian')
return: 返回 limit 个最可能的汉字序列, 但是是 1 个全局最优解和 limit - 1 个局部最优解
并且返回剩余未搜索的拼音
"""
# 初始化, 找出第一个拼音对应的汉字以及 start 和 emission 概率之积 (对数下为相加)
char_and_prob = ((ch, start_vector[ch] + reversed_emission_matrix[pinyin[0]][ch]) for ch in reversed_emission_matrix[pinyin[0]])
# 取出概率最大的 limit 个
V = {char: prob for char, prob in heapq.nlargest(limit, char_and_prob, key=lambda x: x[1])}
for i in range(1, len(pinyin)):
py = pinyin[i]
prob_map = {}
for phrase, prob in V.items():
previous = phrase[-1]
if previous in reversed_transition_matrix and py in reversed_transition_matrix[previous]:
state, new_prob = reversed_transition_matrix[previous][py]
prob_map[phrase + state] = new_prob + prob
if prob_map:
V = prob_map
else:
# 没有概率, 因此没有完全搜索, 返回目前结果和未搜索拼音 pinyin[i:]
return sorted(V.items(), key=lambda x: x[1], reverse=True), pinyin[i:]
return sorted(V.items(), key=lambda x: x[1], reverse=True), ''
最后综合我们的分词功能和维特比算法,即可得到一个较为智能的输入法了。
# 缓存结果, 加快判断
dp = {}
def ime(pinyin: str, limit=7):
'''
输入法函数, 综合分词和维特比算法的最终结果
'''
if pinyin in dp:
return dp[pinyin][:limit]
# 计算结果
result = []
# 获取分词结果
cut = cut_pinyin_with_strategy(normlize_pinyin(pinyin))
# 先尝试完整划分
for pinyin in cut['intact'] + cut['intact_tail']:
vit, remain_pinyin = viterbi(pinyin)
if not remain_pinyin:
result.extend([(pinyin,) + t for t in vit])
# 如果完整划分的最小拼音大小小于等于 3, 则进行纠错
if not result or min([len(pinyin) for pinyin in cut['intact'] + cut['intact_tail']]) <= 3:
for pinyin in cut['error_correction'] + cut['error_correction_tail']:
vit, remain_pinyin = viterbi(pinyin)
if not remain_pinyin:
result.extend([(pinyin,) + t for t in vit])
# 如果结果为空, 则进行模糊划分
if not result:
for pinyin in cut['fuzzy']:
vit, remain_pinyin = viterbi(pinyin)
result.extend([(pinyin,) + t for t in vit])
# 排序并取出前 limit 个
dp[pinyin] = sorted(result, key=lambda x: x[2], reverse=True)
return dp[pinyin][:limit]
if __name__ == '__main__':
print(ime('jintian')) # 基础功能
print(ime('jintain')) # 纠错功能
print(ime('ji\'ntian')) # 分词功能
print(ime('jintiantianqibucuo')) # 短句功能
print(ime('jttqbc')) # 首字母功能
print(ime('nanjingdaxuerengongzhinengxueyuan')) # 南京大学人工智能学院
print(ime('nanjingdx')) # 南京大学
我从 https://www.emojiall.com/zh-hans/all-emojis 中获取了所有的中文和 emoji 的对应数据,保存在了 data/emoji.txt
中。通过代码
def gen_emoji_json():
emoji_dict = {}
with open(emoji_file_path, 'r', encoding='utf-8') as f:
emoji = ''
for line in f:
line = line.strip()
# 跳过数字
if all(ch in '1234567890' for ch in line):
continue
if emoji:
# 去除前缀
if line.startswith('旗: '):
line = line[3:]
emoji_dict[line] = emoji
emoji = ''
else:
emoji = line
# save to data/emoji.json
with open(emoji_json_path, 'w', encoding='utf-8') as f:
json.dump(emoji_dict, f, ensure_ascii=False, indent=4)
def load_emoji_dict():
return json.load(open(emoji_json_path, 'r', encoding='utf-8'))
我生成了如下格式的 emoji 字典,即中文和 emoji 的对应表:
{
"笑脸": "😄",
"苦笑": "😅",
"斜眼笑": "😆",
"微笑天使": "😇",
"呵呵": "🙂",
"倒脸": "🙃",
"笑得满地打滚": "🤣",
"表情脸": "😍",
"花痴": "😍",
"亲亲": "😗",
"飞吻": "😘",
"吐舌脸": "😛",
"好吃": "😋",
"想一想": "🤔",
}
最后我们更新一下 ime()
函数,如果第一个中文有对应 emoji 则在第二位加入 emoji 即可。
def ime(pinyin: str, limit=7):
'''
输入法函数, 综合分词和维特比算法的最终结果, 并且会加入 emoji
'''
def replace_with_emoji(tuples):
'''
如果第一个中文有对应 emoji, 则使用 emoji 将其替换
'''
if tuples and tuples[0][1] in emoji_dict:
return [tuples[0], (tuples[0][0], emoji_dict[tuples[0][1]], tuples[0][2] - 1e-5)] + tuples[1:-1]
else:
return tuples
if pinyin in dp:
return replace_with_emoji(dp[pinyin][:limit])
Web 前端采用了 React 框架,个人比较喜欢 Google 家的 Material Design,因此选用了 MUI,一款基于 React 框架的 Material 组件库。
整个 UI 界面非常简单,由三个主要部分组成。
- 位于左上方的拼音输入法输入框,用以显示当前输入的拼音内容,例如当前为
xiao'lian
,然后通过拼音实时获取到对应的推荐词列表。输入框的右边还会包括一个 emoji 选择按钮。 - 位于左下方的推荐词列表,最多显示 7 个。其中还包括匹配 emoji 的显示。
- 位于右边的文本输入框,会捕获按键并同步输入法输入的内容,可以用于测试输入法的效果。
前端具体的代码比较繁杂,这里也不过多赘述。
This project is licensed under the MIT License.