基于 BK 树的中文拼写纠错候选召回

2023-11-04

最近在研究中文拼写纠错,在查阅资料的时候看到了这篇文章《从编辑距离、BK树到文本纠错 - JadePeng - 博客园》,觉得 BK 树挺有意思的,决定深入研究一下,并在其基础上重新整理一遍,希望能够对各位读者大大们有所帮助。

前置知识

本节介绍实现基于 BK 树的中文拼写纠错候选召回所需要的前置知识,包括文本纠错的主流方案、编辑距离和 BK 树等相关概念。

文本纠错

目前业界主流的方案仍然是以 pipeline 的方式:“错误检测 -> 候选召回 -> 候选排序”的步骤依次进行。以平安寿险纠错方案(《NLP上层应用的关键一环——中文纠错技术简述》)为例,其系统整体架构如下图所示(侵权则删):
pipeline 形式
整体上,平安寿险团队将纠错流程分解为错误检测 -> 候选召回 -> 候选排序三个关键步骤。

  • 错误检测:为了解决资源受限问题以及音近错误问题,提出了基于字音混合语言模型的错误检测算法。该模型可以通过未标注的无监督原始语料进行训练,同时利用字音混合特征限制预测概率分布。
  • 候选召回:结合双数组字典树以及 CSR 存储架构优化整体字典存储方案,同时优化内存空间及索引效果。此外,提出创新性的编辑距离召回算法,利用分层思想重构倒排索引字典存储方式,使得基于编辑距离的搜索变得灵活而高效。
  • 候选排序:加入语言模型预测的语义特征,从而提高模型的判别能力。
    本篇文章主要介绍基于 BK 树的方式来实现候选召回,对于错误检测和候选排序则不予以展开。

编辑距离

编辑距离(Edit Distance,又称莱温斯坦距离 Levenshtein Distance),是指 A、B 两个字符串,由 A 转成 B 所需的最少编辑操作次数。
其中,许可的编辑操作有替换、插入和删除。

  • 替换:将一个字符替换成另一个字符。
  • 插入:插入一个字符。
  • 删除:删除一个字符。

一般来说,编辑距离越小,两个字符串的相似度越大。

A:实现替换操作
B:实现删除操作
A->B:
1. 替 -> 删
2. 换 -> 除
A->B的编辑距离 = 2
A:实现替换操作
B:实现换操作
A->B:
1. 删除“替”
A->B的编辑距离 = 1

关于编辑距离的实现,请参考 72. 编辑距离 - 力扣(LeetCode)。

BK 树

BK 树(Burkhard Keller Tree)是一种数据结构,其核心思想是:令 d(x,y) 表示字符串 x 到 y 的编辑距离。

  • d(x, y) = 0 当且仅当 x = y:编辑距离为 0 <==> 字符串相等。
  • d(x, y) = d(y, x):从 x 变到 y 的最少步数等于从 y 变到 x 的最少步数。
  • d(x, y) + d(y, z) >= d(x, z):从 x 变到 z 所需的步数不会超过 x 先变成 y 再变成 z 的步数,该性质被称为三角不等式,两边之和必然大于第三边。
    在图像上,以词语作为节点,词语之间的距离作为边,从而构造一颗树,如下图所示。
    图 1图1

基于 BK 树的数据结构,我们可以实现候选召回。例如,句子“It’s exactly the game as mine”,其中 game 应该修改为 same。当我们识别到 game 存在错误时,可以借助 BK 树(以编辑距离作为边),找到所有与 game 编辑距离为 1 的单词,例如 same、fame 和 gate,从而实现候选召回。

BK 树的优点在于将查询的时间复杂度从 O ( n ) O(n) O(n)降低至 O ( l o g ( n ) ) O(log(n)) O(log(n)),其中 n 为词表大小。
那么,如何构造一颗 BK 树,并借助构造好的 BK 树来查询单词指定编辑距离的其他单词呢?请看下文。

实现步骤

  1. 构造一颗 BK 树;
  2. 实现 BK 树查询逻辑。

具体实现的语言选择 python。

构造 BK 树

首先,定义 Node Class,作为树的节点。

class Node:

    def __init__(self, word: str):
        self.word = word
        self.branch = {}

Node Class 包含 word 和 branch 两个属性,word 表示节点的词语,branch 表示分支,以字典的形式进行存储,其中 key 为父子节点(词语)之间的编辑距离,value 为子节点。

  1. 接着,随机选择一个词语作为根节点,例如图 1 的 game;
  2. 然后,继续选择下一个词语,例如 same,计算它们之间的编辑距离,将 same 节点作为 game 根节点新的分支;
  3. 继续选择下一个词语,例如 fame,仍然从根节点开始遍历,计算 fame 和 game 的编辑距离,此时发现编辑距离 1 的分支已经存在了(same 与 game 的编辑距离为 1)!此时,沿着这条分支往下走,计算 fame 与 same 的编辑距离,而 same 节点没有编辑距离为 1 的分支,因此将 fame 挂载到 same 节点下,作为 same 节点的新分支。
  4. 依次选择余下的词语,按照步骤 2、3 不断扩展,从而构造出 BK 树。

【实现代码】:

import tqdm



class BKTree:

    def __init__(self):
        self.root = None
        self.word_list = []

    def build(self, word_list: list) -> None:
        """
        构建 BK 树
        :param word_list: list 词语列表,[game, fame, ..., time]
        :return: None
        """
        if not word_list:
            return None

        self.word_list = word_list

        # 首先,挑选第一个词语作为 BK 树的根结点
        self.root = Node(word_list[0])

        # 然后,依次往 BK 树中插入剩余的词语
        for word in tqdm.tqdm(word_list[1:]):
            self._build(self.root, word)

    def _build(self, parent_node: Node, word: str) -> None:
        """
        具体实现函数:构建 BK 树
        :param parent_node: Node 父节点
        :param word:        str  待添加到 BK 树的词语
        :return: None
        """
        dis = edit_distance(parent_node.word, word)

        # 判断当前距离(边)是否存在,若不存在,则创建新的结点;否则,继续沿着子树往下递归
        if dis not in parent_node.branch:
            parent_node.branch[dis] = Node(word)
        else:
            self._build(parent_node.branch[dis], word)


if __name__ == "__main__":
    word_list = ["game", "fame", "same", "frame", "gain", "gay", "gate", "home", "aim", "acm"] 
    bk_tree = BKTree() 
    bk_tree.build(word_list)

让我们再来观察一下图 1 以及 BK 树的构建过程,我们可以发现:根节点 game 分支 1 下的所有子孙节点和它的编辑距离都为 1,分支 2 下的所有子孙节点和它的编辑距离都为 2;同理,节点 fame 分支 1 下的所有子孙节点和它的编辑距离都为 1。这个性质很有用,在借助 BK 树进行查询时可以减少计算量,并且可以将高词频的词语放在 BK 树的顶部,而不常见的词语添加到 BK 树的底部,这样也能够减少计算量(为什么可以减少计算量,读者们可以先思考一下)。

【实现代码】:使 build() 函数支持输入词频字典。

def build(self, word_list: list or dict) -> None:
    """
    构建 BK 树
    :param word_list: list or dict 词语列表或者词频字典
    :return: None
    """
    if not word_list:
        return None

    # 如果是词频字典形式,则将其按照词频降序排列,得到词语列表
    if type(word_list) == dict:
        word_list = [item[0] for item in sorted(word_list.items(), key=lambda x: x[1], reverse=True)]
    self.word_list = word_list

    # 首先,挑选第一个词语作为 BK 树的根结点
    self.root = Node(word_list[0])

    # 然后,依次往 BK 树中插入剩余的词语
    for word in tqdm.tqdm(word_list[1:]):
        self._build(self.root, word)

【词频字典】:

word_count_dict = { 
    "game": 5, 
    "fame": 3, 
    "same": 7, 
    "frame": 2, 
    "gain": 1, 
    "gay": 1, 
    "gate": 3, 
    "home": 6, 
    "aim": 5, 
    "acm": 1 
}

BK 树查询
BK 树查询的本质是多叉树的遍历,找到与查询词语编辑距离在指定范围内的词语,例如找到与 same 编辑距离小于 2 的词语。该查询过程的难点在于剪枝的过程——即如何在当前节点上选择分支(node.branch)。

图 1图 1

以图 1 为例,要找到与 same 编辑距离小于 2 的词语,我们可以分两步走:

  • same 节点分支小于 2 的子树上的所有节点与 same 的编辑距离都小于 2,这是我们在上一小节构造 BK 树中发现的性质。
  • 另一部分就比较麻烦了,可能是 same 的祖先节点,也可能在其他分支上,例如 gain 或者 aim 分支上面。这部分就需要遍历节点,然后依次计算查询词语(same)与各节点之间的编辑距离,并确定要不断遍历的分支。假设,在 BK 树的查询过程中,我们此时位于根节点 game,计算 same 与 game 的编辑距离为 1,满足条件,将 game 添加到返回结果中。接着,有三条分支可以走,那么问题来了,要选择哪几条分支呢?全都要,那就失去了 BK 树查询的优点,和线性查询没有区别。因此,我们需要挑选合适的分支,实现剪枝操作,从而减少计算量。那么到底该怎么做呢?

还记得 BK 树的核心思想吗?其中第三条,三角不等式性质!该性质是解决问题的关键(在查阅的资料中都一笔带过,没有详细讲解这一点,希望我这篇文章能够把其中的缘由介绍清楚)!我们的目标是找到 d(x, y) <= n 的所有词语,其中 y 是查询词语,x 是目标词语,n 是我们设定的编辑距离阈值。举个例子,gate 是查询词语,我们要找到与 gate 编辑距离为 3 的词语,通过图 1 可知 frame 和 aim 是目标词语(即我们要找到的词语)。我们沿着根节点不断遍历 BK 树,假设此时位于根节点 game,计算 gate 与 game 的编辑距离为 1。根据三角不等式性质, n + d ( g a t e , g a m e ) ≥ d ( g a m e , b r a n c h ) n + d(gate, game) \geq d(game, branch) n+d(gate,game)d(game,branch),即 3 + d ( g a t e , g a m e ) = 4 ≥ d ( f r a m e , b r a n c h ) 3 + d(gate, game) = 4 \geq d(frame, branch) 3+d(gate,game)=4d(frame,branch),换言之 game 节点 branch 小于等于 4 的分支都是满足条件的。

接着举例,我们继续往下遍历,走到 gain 节点,计算 d(gate, gain) = 2,那么 2 + d ( g a t e , g a i n ) = 4 ≥ d ( g a i n , b r a n c h ) 2 + d(gate, gain) = 4 \geq d(gain, branch) 2+d(gate,gain)=4d(gain,branch),gain 节点 branch 小于等于 4 的分支都是满足条件的。往下走到达 frame,计算 frame 与 gate 的编辑距离为 3,将其添加到结果列表中。

上述过程解释起来仍然有些“绕”,让我们换个视角,看图 2(仍然沿用上述过程中使用的例子,gate 是查询词语,找到所有与 gate 编辑距离为 3 的词语)。
图 2图 2

假设此时抵达了节点 gain,因为我们事先并不知道 gain 的所有子节点中是否存在与查询词语 gate 编辑距离为 3 的词语。为了不遗漏,最简单的做法就是全部遍历一遍,但这样处理的后果就是退化为了线性查询。因此,现在的目标是在不遗漏符合条件词语的前提下,尽可能减少需要遍历的分支。那么,我们可以假设,查询词语 gate 和 gain 的子节点中存在编辑距离为 3 的词语,从而确保不会遗漏。而 gate 和 gain 的编辑距离 d(gate, gain) = 2。根据三角不等式性质, d ( g a i n , ? ) ≤ d ( g a t e , ? ) + d ( g a t e , g a i n ) d(gain, ?) \leq d(gate, ?) + d(gate, gain) d(gain,?)d(gate,?)+d(gate,gain),因此我们可以求出 d(gain, ?) 的值范围,从而确定接下来要遍历的分支,并且确保不会遗漏符合条件的词语。

【实现代码】:

def query(self, query_word: str, n: int) -> list:
    """
    BK 树查询
    :param query_word: str 查询词语
    :param n:          int 编辑距离
    :return: list 符合距离范围的词语列表
    """
    result = []

    self._traverse_judge_and_get(query_word, n, self.root, result)
    return result

def _traverse_judge_and_get(self, query_word: str, n: int, node: Node, result: list) -> None:
    """
    具体实现函数:BK 树查询
    :param query_word: str  查询词语
    :param n:          int  编辑距离
    :param node:       Node 当前节点
    :param result:     list 符合距离范围的词语列表
    :return: None
    """
    if not node:
        return None

    dis = edit_distance(query_word, node.word)

    # 根据三角不等式来确定查询范围,以实现剪枝的目的
    left, right = max(1, dis - n), dis + n
    
    # 若找到查询词语所在的节点,则直接将其所有子孙节点添加到 result 中
    if dis == 0:
        for dis in range(left, right + 1):
            if dis in node.branch and dis == n:
                self._traverse_and_get(node.branch[dis], result)                
        return None

    # 反之,则不断遍历节点,在查询范围内找到符合编辑距离范围的词语
    for dis_range in range(left, right + 1):
        if dis_range in node.branch:
            dis_branch = edit_distance(query_word, node.branch[dis_range].word)

            # 符合距离范围的词语,将其添加到 result 列表中
            if dis_branch == n:
                result.append(node.branch[dis_range].word)

            # 继续沿着子节点遍历,直到叶子节点
            self._traverse_judge_and_get(query_word, n, node.branch[dis_range], result)

def _traverse_and_get(self, node: Node, result: list) -> None:
    """
    遍历 BK 树并获取遍历节点的词语
    :param node:       Node 当前节点
    :param result:     list 符合距离范围的词语列表
    :return: None
    """
    if not node:
        return None

    result.append(node.word)

    for dis, node_branch in node.branch.items():
        self._traverse_and_get(node_branch, result)

在上述代码的基础上我们还可以做改进,将编辑距离阈值 n 扩展为一个由 min_dist 和 max_dist 组成的编辑距离范围。

【改进代码】:通过黄色字体将改进代码标识出来(CSDN 不显示,可以移步至我的飞书文档)。

def query(self, query_word: str, max_dist: int, min_dist: int = 0) -> list:
    """
    BK 树查询
    :param query_word: str 查询词语
    :param max_dist:   int 最大距离
    :param min_dist:   int 最小距离
    :return: list 符合距离范围的词语列表
    """
    result = []

    self._traverse_judge_and_get(query_word, max_dist, min_dist, self.root, result)
    return result

def _traverse_judge_and_get(self, query_word: str, max_dist: int, min_dist: int, node: Node, result: list) -> None:
    """
    具体实现函数:BK 树查询
    :param query_word: str  查询词语
    :param max_dist:   int  最大距离
    :param min_dist:   int  最小距离
    :param node:       Node 当前节点
    :param result:     list 符合距离范围的词语列表
    :return: None
    """
    if not node:
        return None

    dis = edit_distance(query_word, node.word)

    # 根据三角不等式来确定查询范围,以实现剪枝的目的
    left, right = max(1, dis - max_dist), dis + max_dist
        
    if dis == 0:
        for dis in range(left, right + 1):
            if dis in node.branch and min_dist <= dis <= max_dist:
                self._traverse_and_get(node.branch[dis], result)                
        return None

    for dis_range in range(left, right + 1):
        if dis_range in node.branch:
            dis_branch = edit_distance(query_word, node.branch[dis_range].word)

            # 符合距离范围的词语,将其添加到 result 列表中
            if min_dist <= dis_branch <= max_dist:
                result.append(node.branch[dis_range].word)

            # 继续沿着子节点遍历,直到叶子节点
            self._traverse_judge_and_get(query_word, max_dist, min_dist, node.branch[dis_range], result)

def _traverse_and_get(self, node: Node, result: list) -> None:
    """
    遍历 BK 树并获取遍历节点的词语
    :param node:       Node 当前节点
    :param result:     list 符合距离范围的词语列表
    :return: None
    """
    if not node:
        return None

    result.append(node.word)

    for dis, node_branch in node.branch.items():
        self._traverse_and_get(node_branch, result)

PS:我们也可以使用其他的距离公式来替换编辑距离,只要该距离公式符合 BK 树的核心思想即可。

此外,还记得在上一节留下的思考题吗?为什么将高词频的词语放在 BK 树的顶部,不常见的词语添加到 BK 树的底部,可以减少计算量?这是因为将高频词语放在 BK 树的顶部,能够更快找到高频词语所在的节点,这样就可以直接将该节点下符合条件分支的所有子孙节点都添加到结果列表中,而不需要再依次计算查询词语与各节点的编辑距离,从而减少了计算量。

完整代码

完整的代码请参考:https://github.com/clvsit/nlp_simple_task_impl/blob/master/script/bk_tree/bk_tree.py

如有错误,还望指出,不胜感激。

参考

  • 从编辑距离、BK树到文本纠错:https://www.cnblogs.com/xiaoqi/p/BK-Tree.html
本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

基于 BK 树的中文拼写纠错候选召回 的相关文章

  • python虚拟环境的概念,配置和操作(pycharm为例)

    一 虚拟环境的由来和概念 1 在PyCharm中创建python项目时 需要配置python的运行环境 除了使用系统现有环境以外 还可以创建虚拟环境 2 虚拟环境的创建是因为在实际开发中需要同期用到不同版本的python解释器 不同的第三方

随机推荐

  • 微信小程序之蓝牙 BLE 踩坑记录

    前言 前段时间接手了一个微信小程序的开发 主要使用了小程序在今年 3 月开放的蓝牙 API 此过程踩坑无数 特此记录一下跳坑过程 顺便开了另一个相关的小项目 欢迎 start 和 fork BLE MiniProgram API简介 微信小
  • vue 富文本vue-quill-editor 自定义字号大小和字体样式

    一 先创建一个名为font的 css文件 然后引入自定义css 字体样式大小 ql snow ql picker ql font ql picker label data value SimSun before ql snow ql pic
  • Unity修改开始画面

    下面 是我换好的开始画面 中间的是开始淡入淡出画面的第一个 后面的是背景 还有就是如果不要最后的背景的话 那么就是一个纯色的背景 颜色可调
  • 干货!情感计算理论基础必看知识!!

    产生背景 在较长一段时期内 情感一直位于认知科学研究者的视线以外 直到20世纪末期 情感作为认知过程重要组成部分的身份才得到了学术界的普遍认同 当代的认知科学家们把情感与知觉 学习 记忆 言语等经典认知过程相提并论 关于情感本身及情感与其他
  • 假设检验之单个总体均值检验(含Python代码)

    什么是假设检验 教材原话 很容易理解 假设检验原理图 均值假设检验原理 理解重点 单个总体均值假设检验步骤 分位数的理解 附教材 统计量规定 单个总体检验的扩展 分为三类情况讨论 单侧检验 重点 Python代码 含例题答案 def zte
  • ./gophish: /lib64/libc.so.6: version `GLIBC_2.28‘ not found (required by ./gophish)

    本文向大家介绍Centos系统在启动一些服务的时候会碰到的 lib64 libc so 6 version GLIBC 2 28 not found required by 的问题的解决方法 根据提示可知碰到此问题 是因为没有找到GLIBC
  • ubuntu安装SLAM所需的库的方法

    一 安装Eigen3 安装步骤 从Github下载eigen3 git clone https github com eigenteam eigen git mirror sudo apt get install libeigen3 dev
  • 背包问题专题-动态规划

    目录 01背包问题 完全背包 多重背包 多重背包的优化 分组背包 01背包问题 有 N 件物品和一个容量是 V 的背包 每件物品只能使用一次 第 i 件物品的体积是 vi 价值是 wi 求解将哪些物品装入背包 可使这些物品的总体积不超过背包
  • Linux用户及用户组管理命令

    http blog sina com cn s blog 857e9db00101l6u7 html Linux用户及用户组管理命令 Linux系统是一个多用户多任务的分时操作系统 任何一个要使用系统资源的用户 都必须首先向系统管理员申请一
  • TCP原理之:TCP数据传输

    TCP原理之 TCP数据传输 TCP Transmission Control Protocol 全称为传输控制协议 它工作在网络七层模型中的第四层 传输层 是一种面向连接的可靠的数据传递协议 对于IP和UDP协议 它们会在接收到数据后根据
  • 钉钉如何实现原笔迹手写签批

    钉钉目前实现了简单的手写签字功能 与无纸化办公要求相比还有差距 在一些专业领域 特别时电子政务领域 原笔迹手写是作为最核心的功能之一 为实现原笔迹签批 通过外部应用来实现是明智之举 下面我来介绍一款无纸化办公的神器 这款独立的办公产品名称为
  • Linux查看当前目录下文件名中包含指定字符的文件

    find type f name edaijia 结果 转载于 https www cnblogs com wt645631686 p 8837163 html
  • Js 对象转数组对象 for...in

    遇到一个情况是需要把对象转成数组对象 用的是for in 不知道各位大佬们有没有其它方法可以贴出来学习学习 话不多说 直接贴代码和效果 let obj 未完成 5 已完成 8 待确认 4 已取消 6 let arr for let i in
  • 点击按钮button触发修改div的iframe内容 (修改iframe的src内容)

    新建一个html文件 比如test html 之后将如下内容复制进文件 然后用浏览器打开该html文件即可 table style height 100 width 100 tr td td tr table
  • 决策树系列4:随机森林 (三个臭皮匠顶个诸葛亮)

    引言 想了解一个人的人品一般询问多个熟悉他的人 而不是只问一个人 毕竟一个人的评价难免有失偏颇 对于决策树也是一样 单一的决策树有时候会过拟合 有时候效果不太理想 而随机森林就是利用多棵决策树共同做决策 毕竟三个臭皮匠顶个诸葛亮嘛 随机森林
  • python---数据清洗

    干净整洁的数据是后续进行研究和分析的基础 数据科学家们会花费大量的时间来清理数据集 毫不夸张地说 数据清洗会占据他们80 的工作时间 而真正用来分析数据的时间只占到20 左右 所以 数据清洗到底是在清洗些什么 通常来说 你所获取到的原始数据
  • Kafka学习--3、Kafka Broker、节点服役和退役、Kafka 副本、Leader 选举流程、故障处理

    1 Kafka Broker 1 1 Kafka Broker工作流程 1 1 1 Zookeeper储存的Kafka信息 1 启动Zookeeper集群 再启动Kafka集群 然后启动Zookeeper客户端 2 通过ls命令可以查看ka
  • 爬虫(20)Scrapy知识补充+腾讯招聘案例+古诗文详情页+总结

    文章目录 第十八章 腾讯招聘案例 1 腾讯招聘案例 2 代码实现 2 1 配置项目 2 2 解析数据 2 3 翻页处理 2 4 获取详情页信息 3 古诗词网补充 3 1 验证是否在源码中 3 2 获取详情页地址 3 3 项目补充 3 4 发
  • 算术逻辑单元

    1 什么是算术逻辑单元 1命名 简称ALU Arithmetic Logic Unit 2组成 ALU有2个单元 1个算术单元和1个逻辑单元 Arithmetic Unit和Logic Unit 3作用 计算机中负责运算的组件 处理数字 逻
  • 基于 BK 树的中文拼写纠错候选召回

    最近在研究中文拼写纠错 在查阅资料的时候看到了这篇文章 从编辑距离 BK树到文本纠错 JadePeng 博客园 觉得 BK 树挺有意思的 决定深入研究一下 并在其基础上重新整理一遍 希望能够对各位读者大大们有所帮助 前置知识 本节介绍实现基