别再死记硬背了!用Python手把手带你实现维特比算法(附完整代码与分词实战)
·
用Python实战维特比算法:从动态规划到中文分词
在自然语言处理领域,分词是文本分析的第一步。想象一下,当你拿到一串连续的中文字符"经常有意见分歧"时,如何让计算机理解哪些字应该组合成词?这就是我们今天要解决的核心问题。不同于传统的字典匹配方法,我们将采用维特比算法——这个源自通信领域的动态规划方法,能够优雅地解决中文分词中的最优路径问题。
1. 动态规划与维特比算法基础
维特比算法的本质是动态规划在序列问题中的应用。它通过逐步构建最优解,避免了暴力搜索带来的计算爆炸问题。让我们用一个简单的例子理解其核心思想:
假设我们要从城市A到城市D,中间经过B和C两个中转站,各段路径的距离如下:
graph = {
'A': {'B1': 5, 'B2': 3},
'B1': {'C1': 6, 'C2': 2},
'B2': {'C1': 1, 'C2': 4},
'C1': {'D': 3},
'C2': {'D': 5}
}
传统方法会计算所有路径的组合:
- A→B1→C1→D = 5+6+3 = 14
- A→B1→C2→D = 5+2+5 = 12
- A→B2→C1→D = 3+1+3 = 7
- A→B2→C2→D = 3+4+5 = 12
维特比算法则采用更聪明的方式:
- 到达B1的最短路径:A→B1 (5)
- 到达B2的最短路径:A→B2 (3)
- 到达C1的最短路径:min(5+6, 3+1) = 4 (A→B2→C1)
- 到达C2的最短路径:min(5+2, 3+4) = 7 (A→B1→C2)
- 到达D的最短路径:min(4+3, 7+5) = 7 (A→B2→C1→D)
关键优势 :每步只保留到达当前节点的最短路径,大幅减少计算量。
2. 中文分词的图表示
将中文句子转化为有向无环图(DAG)是应用维特比算法的前提。对于句子"经常有意见分歧",我们需要:
-
构建词典:["经常","有","意见","分歧"]
-
定义节点位置:
0 1 2 3 4 5 6 7 经 常 有 意 见 分 歧 -
创建边表示可能的词语:
dag = {
0: {0: (0, "")},
1: {0: (20, "经")},
2: {0: (2.52, "经常"), 1: (20, "常")},
3: {2: (3.21, "有")},
4: {3: (20, "意")},
5: {2: (6.21, "有意见"), 3: (2.52, "意见"), 4: (5.30, "见")},
6: {5: (3.9, "分")},
7: {5: (3.21, "分歧"), 6: (5.29, "歧")}
}
其中边的权重采用负对数概率:
- P("经常")=0.08 → -log(0.08)≈2.52
- 不在词典中的单字默认权重20
3. Python实现维特比算法
现在让我们用Python实现完整的维特比分词器:
import math
from collections import OrderedDict
class ViterbiSegmenter:
def __init__(self, word_dict, word_prob):
self.word_dict = word_dict
self.word_prob = word_prob
# 转换为负对数概率
self.neg_log_prob = {w: -math.log(p) for w, p in word_prob.items()}
self.default_cost = 20 # 默认词代价
def build_dag(self, sentence):
"""构建有向无环图"""
dag = {}
length = len(sentence)
# 初始化节点
for i in range(length + 1):
dag[i] = {}
# 添加边
for start in range(length):
for end in range(start + 1, length + 1):
word = sentence[start:end]
if word in self.word_dict:
cost = self.neg_log_prob.get(word, self.default_cost)
dag[end][start] = (cost, word)
elif end == start + 1: # 单字
dag[end][start] = (self.default_cost, word)
return dag
def viterbi(self, dag):
"""维特比算法求解最短路径"""
f = OrderedDict() # 保存各节点的最优路径
f[0] = (0, 0) # (累计代价, 前驱节点)
# 前向传播计算最优路径
for node in dag:
if node == 0:
continue
# 找出到当前节点的最优前驱
min_cost = float('inf')
best_prev = None
for prev_node in dag[node]:
cost, word = dag[node][prev_node]
total_cost = cost + f[prev_node][0]
if total_cost < min_cost:
min_cost = total_cost
best_prev = prev_node
f[node] = (min_cost, best_prev)
# 回溯找出最优路径
path = []
end_node = len(dag) - 1
while end_node > 0:
prev_node = f[end_node][1]
word = dag[end_node][prev_node][1]
path.append(word)
end_node = prev_node
path.reverse()
return path
def segment(self, sentence):
dag = self.build_dag(sentence)
return self.viterbi(dag)
# 使用示例
if __name__ == "__main__":
word_dict = ["经常", "有", "意见", "分歧"]
word_prob = {
"经常": 0.08,
"有": 0.04,
"意见": 0.08,
"分歧": 0.04
}
segmenter = ViterbiSegmenter(word_dict, word_prob)
result = segmenter.segment("经常有意见分歧")
print("分词结果:", "/".join(result))
执行结果:
分词结果: 经常/有/意见/分歧
4. 算法优化与扩展
基础实现虽然能工作,但在实际应用中还需要考虑以下优化:
1. 概率平滑处理
- 添加一元语法和二元语法概率
- 使用回退平滑处理OOV(未登录词)
def get_word_prob(word, prev_word=None):
# 实现回退平滑
if prev_word:
bigram = f"{prev_word} {word}"
if bigram in bigram_prob:
return bigram_prob[bigram]
return unigram_prob.get(word, default_prob)
2. 性能优化技巧
- 使用堆结构加速最小查找
- 实现剪枝策略,提前丢弃低概率路径
- 采用对数空间计算避免浮点下溢
# 剪枝示例
if current_cost > threshold * best_cost:
continue # 跳过该路径
3. 多维度特征融合
- 除了词频,加入词性、语义等特征
- 使用机器学习模型预测转移概率
# 特征工程示例
features = {
"word_freq": log_prob,
"pos_match": current_pos == next_pos,
"word_length": len(word)
}
4. 实时分词与增量处理
- 实现流式处理,适用于长文本
- 支持中途修正和交互式分词
实际工程中,优秀的开源分词工具如Jieba、HanLP等都采用了类似的动态规划思想,但加入了更多语言特征和优化策略。
5. 从分词到序列标注
维特比算法的应用不仅限于分词,它还是序列标注任务(如词性标注、命名实体识别)的核心算法。只需将"词"替换为"标签",同样的框架就能适用:
# 词性标注示例
states = ["NN", "VB", "JJ"] # 词性标签集
transitions = {
"NN": {"NN": 0.1, "VB": 0.3, "JJ": 0.6},
# 其他转移概率...
}
emissions = {
"NN": {"苹果": 0.8, "吃": 0.1, "红": 0.1},
# 其他发射概率...
}
def viterbi_pos_tagging(sentence):
# 实现类似分词的维特比算法
pass
这种统一框架使得我们可以用相似的代码解决多种NLP问题,这正是动态规划的魅力所在。
更多推荐

所有评论(0)