视频加载失败

课程

3693 字
约 11 分钟

4.2.3 案例中 `Bigram._forwardSplitSentence` 函数分析

自然语言处理labs/lab/lab01/docs·更新于 2026-09-15

4.2.3 案例中 Bigram._forwardSplitSentence 函数分析

源码位置:chapter04/4.2.3/4.2.3.py:13

1. 函数作用

_forwardSplitSentence(self, sentence, word_max_len=5) 的作用是:

  • 对输入句子进行前向递归切分
  • 枚举出该句子的所有可能切分方案
  • 为后续 _maxP() 函数做准备;
  • 最终由 _maxP() 在这些候选方案中选出二元语法概率最大的那一种。

也就是说,这个函数本身不直接决定最终答案,它的任务是“先把所有可能的切法找出来”。


2. 带注释的函数代码

def _forwardSplitSentence(self, sentence, word_max_len=5):
    split_groups = []
    sentence = sentence.strip()
    sentence_len = len(sentence)

    if sentence_len < 2:
        return [[sentence]]

    range_len = [sentence_len, word_max_len][sentence_len > word_max_len]
    current_groups = []
    single_cut = True

    for i in range(1, range_len)[::-1]:
        if self.DICT.__contains__(sentence[:i]) and i != 1:
            current_groups.append([sentence[:i], sentence[i:]])
            single_cut = False

    if single_cut or self.DICT.__contains__(sentence[1:3]):
        current_groups.append([sentence[:1], sentence[1:]])

    if sentence_len == 2:
        current_groups.append([sentence])

    for one_group in current_groups:
        if len(one_group) == 1:
            split_groups.append(one_group)
            continue

        for child_group in self._forwardSplitSentence(one_group[1]):
            child_group.insert(0, one_group[0])
            split_groups.append(child_group)

    return split_groups

下面给出逐句注释:

  1. split_groups = []

    • 保存当前 sentence 的所有完整切分方案。
  2. sentence = sentence.strip()

    • 去掉首尾空白字符,避免影响分词。
  3. sentence_len = len(sentence)

    • 记录当前待切分串的长度。
  4. if sentence_len < 2: return [[sentence]]

    • 递归终止条件。
    • 当只剩 0 或 1 个字时,已经无法继续切分,直接把它看成一种方案返回。
    • 返回的是二维列表,例如 [["我"]],因为外层列表表示“方案集合”。
  5. range_len = [sentence_len, word_max_len][sentence_len > word_max_len]

    • 取“句长”和“最大词长”中的较小者。
    • 作用是限制前缀扫描的最大长度,避免枚举过长的前缀。
  6. current_groups = []

    • 保存当前层的“二切分”结果。
    • 例如把 喜欢观赏日出 先切成 ["喜欢", "观赏日出"]
  7. single_cut = True

    • 标记当前是否还没有找到合法词典前缀。
    • 如果最后仍然为 True,就说明只能退化成“第一个字单独切出”。
  8. for i in range(1, range_len)[::-1]

    • 从长到短扫描前缀长度。
    • 例如 range_len = 5 时,依次检查前缀长度 4、3、2、1
    • 这体现了“前向最大匹配”的思想:优先尝试较长前缀。
  9. if self.DICT.__contains__(sentence[:i]) and i != 1

    • 如果前 i 个字在词典里,且 i != 1,就把它当作一个合法前缀词。
    • 这里故意不在循环里处理 i == 1,因为“单字切分”统一由后面的回退逻辑控制。
  10. current_groups.append([sentence[:i], sentence[i:]])

    • 形成一个二切分结果:前缀词 + 剩余串。
  11. single_cut = False

    • 说明已经找到过词典中的合法前缀,不属于“完全匹配不到”的情况。
  12. if single_cut or self.DICT.__contains__(sentence[1:3])

    • 两种情况下,从第 1 个字后切一刀:
      • 当前没有任何合法前缀词;
      • sentence[1:3] 在词典中,说明后面两个字可能构成词,保留“首字单独成词”的可能。
  13. current_groups.append([sentence[:1], sentence[1:]])

    • 把句子切成“第一个字 + 剩余部分”。
    • 这样能提高对未登录词、歧义情况的覆盖能力。
  14. if sentence_len == 2: current_groups.append([sentence])

    • 当当前串长度正好为 2 时,额外保留“不切分”的可能。
    • 例如 日出 既可能切成 日/出,也可能整体作为一个双字词。
  15. for one_group in current_groups

    • 遍历当前层生成的所有二切分结果。
  16. if len(one_group) == 1

    • 说明这是“长度为 2 时整体保留”的情形,例如 ["日出"]
    • 已经是一条完整方案,直接加入结果。
  17. for child_group in self._forwardSplitSentence(one_group[1])

    • 对后半部分递归切分。
    • 当前函数只负责确定“第一个词”,其余部分交给递归继续处理。
  18. child_group.insert(0, one_group[0])

    • 把当前层切出的前缀词插入到子方案的最前面。
  19. split_groups.append(child_group)

    • 得到一条完整切分路径,加入总方案集合。
  20. return split_groups

    • 返回当前句子的所有候选切分结果。

3. 算法详细过程

该函数本质上是一个“前向扫描 + 递归枚举”算法。

第一步:预处理和递归终止

  • 先去空格;
  • 再判断长度;
  • 如果句子长度小于 2,就直接返回。

这是递归算法的基线条件。

第二步:在当前句子上做“第一刀”

函数会在当前句子的前部寻找可作为“第一个词”的前缀。

  • 前缀长度从大到小枚举;
  • 只要某个前缀在词典里,就记为一种二切分方案;
  • 这样可以同时保留多个候选前缀,而不是只保留最长的那一个。

例如某串既可以前切为 研究生/命起源,又可以前切为 研究/生命起源,该函数会把这些可能性都保留下来。

第三步:必要时加入“单字切分”回退方案

如果完全找不到词典前缀,算法不能停止,否则句子就会丢失。

所以它会回退到:

  • 把第一个字单独切出来;
  • 剩余部分继续递归处理。

这一步保证了任何句子都能被切分

第四步:长度为 2 时额外保留整体成词

双字串在中文里很常见,例如:

  • 日出
  • 学习
  • 中文

因此当剩余串长度等于 2 时,程序会同时保留:

  • 切开:日/出
  • 不切:日出

为后续概率比较留下空间。

第五步:递归求解后半部分

对于当前得到的每个二切分 [前缀, 后缀]

  • 前缀已经确定;
  • 后缀继续调用 _forwardSplitSentence()
  • 递归返回后,再把前缀接回每个子方案前面。

这样就能把局部二切分逐层扩展成完整句子的切分方案。

第六步:返回所有候选方案

函数最终返回的是一个二维列表,例如:

[
    ['我', '喜欢', '观赏', '日', '出'],
    ['我', '喜欢', '观赏', '日出']
]

这些候选并不代表最终结果,真正的最优方案由 _maxP() 用二元语法概率再筛选一次。


4. 以“我喜欢观赏日出”为例说明执行过程

程序主函数中的测试句为:我喜欢观赏日出

在当前词典下,_forwardSplitSentence() 共生成 2 种候选方案

['我', '喜欢', '观赏', '日', '出']
['我', '喜欢', '观赏', '日出']

具体递归过程如下。

第 1 层:处理“我喜欢观赏日出”

  • 句长大于 2,进入扫描;
  • 由于前部没有更长合法前缀被保留,程序回退到单字切分;
  • 得到:['我', '喜欢观赏日出']

第 2 层:处理“喜欢观赏日出”

  • 扫描到合法前缀 喜欢
  • 得到:['喜欢', '观赏日出']

第 3 层:处理“观赏日出”

  • 扫描到合法前缀 观赏
  • 得到:['观赏', '日出']

第 4 层:处理“日出”

因为长度等于 2,所以会保留两种情况:

  • ['日', '出']
  • ['日出']

于是向上回溯后,得到完整方案:

  1. 我 / 喜欢 / 观赏 / 日 / 出
  2. 我 / 喜欢 / 观赏 / 日出

接着 _maxP() 比较这两种方案的二元概率,最后输出:

['我', '喜欢', '观赏', '日出']

5. 算法特点总结

优点

  • 能枚举多种候选切分,避免单纯最大匹配的局限;
  • 通过递归把局部切分扩展为全局方案;
  • 能处理未登录词,因为保留了单字回退策略;
  • 给后续概率模型提供了充分候选空间。

局限

  • 候选方案数可能随着句长增加而增多;
  • _forwardSplitSentence() 只负责“列举方案”,不负责判断优劣;
  • 最终效果依赖词典覆盖率以及 _maxP() 中的二元概率质量。

6. 一句话结论

_forwardSplitSentence() 的核心思想是:

先从左到右找出所有可能的前缀词,再递归切分剩余子串,最终生成整句的所有候选切分方案,供二元语法模型选择最优结果。

Profile Image of the Author
Sonder
好想要技术
这是公告标题
这只是一个公告
分类
标签
站点信息
构建平台
GitHub Actions
博客版本
Firefly v6.16.7
文章许可
CC BY-NC-SA 4.0
文章目录