别再死记硬背了!用Python手撸一个HMM分词器,从训练到预测保姆级教程

张开发
2026/4/20 16:09:31 15 分钟阅读

分享文章

别再死记硬背了!用Python手撸一个HMM分词器,从训练到预测保姆级教程
用Python实现HMM中文分词从理论到实践的完整指南中文分词是自然语言处理的基础任务之一而隐马尔可夫模型(HMM)作为一种经典的统计学习方法在分词领域有着广泛应用。本文将带你从零开始实现一个完整的HMM分词器不仅理解其数学原理更能掌握工程实现中的各种技巧。1. HMM分词基础概念在开始编码之前我们需要明确几个核心概念。HMM模型假设系统是一个马尔可夫过程即当前状态只依赖于前一个状态。在中文分词中我们通常采用BMES标注体系B(Begin)词语的开始字M(Middle)词语的中间字E(End)词语的结束字S(Single)单字成词这种标注方法能够清晰地表示词语的边界。例如自然语言处理会被标注为B M E B E。HMM模型包含三个核心参数初始概率分布(π)句子第一个字属于各状态的概率转移概率矩阵(A)从一个状态转移到另一个状态的概率发射概率矩阵(B)在某个状态下观察到特定字符的概率实际应用中我们会使用对数概率来避免数值下溢问题这在后续的代码实现中会特别说明。2. 数据准备与预处理任何机器学习项目都始于数据准备。对于HMM分词器我们需要有标注好的训练语料。这里我们使用人民日报分词语料作为示例。2.1 语料格式处理原始语料通常是已经分好词的文本例如中国 人民 银行 宣布 降息我们需要将其转换为字符级别的BMES标签。处理函数如下def make_label(text): if not text: return [] if len(text) 1: return [S] return [B] [M]*(len(text)-2) [E]2.2 统计参数计算HMM的参数需要通过统计学习得到。我们需要统计每个状态的初始出现次数状态间的转移次数每个状态下观察到特定字符的次数def init_parameters(self): self.start_p {state: 0.0 for state in self.state_list} self.trans_p {state: {s: 0.0 for s in self.state_list} for state in self.state_list} self.emit_p {state: {} for state in self.state_list}3. 模型训练实现有了基础统计量后我们可以计算HMM的三个核心概率矩阵。3.1 概率计算与平滑直接使用最大似然估计会出现零概率问题我们需要进行平滑处理。这里采用加一平滑(Laplace平滑)# 初始概率计算 self.start_p {k: (v 1) / (line_nb len(self.state_list)) for k, v in self.start_p.items()} # 转移概率计算 self.trans_p {k: {k1: (v1 1) / (state_dict[k] len(self.state_list)) for k1, v1 in v0.items()} for k, v0 in self.trans_p.items()} # 发射概率计算 self.emit_p {k: {k1: (v1 1) / (state_dict[k] len(v0)) for k1, v1 in v0.items()} for k, v0 in self.emit_p.items()}3.2 模型保存与加载训练好的模型需要保存以便后续使用Python的pickle模块非常适合这个任务def save_model(self, path): with open(path, wb) as f: pickle.dump({ start_p: self.start_p, trans_p: self.trans_p, emit_p: self.emit_p }, f) def load_model(self, path): with open(path, rb) as f: params pickle.load(f) self.start_p params[start_p] self.trans_p params[trans_p] self.emit_p params[emit_p] self.trained True4. Viterbi算法实现Viterbi算法是HMM解码的核心用于找到最可能的状态序列。4.1 算法原理Viterbi算法是一种动态规划算法其核心思想是初始化第一个字符属于各状态的概率递推计算每个时间步各状态的最大概率路径回溯找到全局最优路径4.2 Python实现为了避免数值下溢我们使用对数概率进行计算def __viterbi(self, text): V [{}] path {} # 初始化 for y in self.state_list: V[0][y] math.log(self.start_p.get(y, 1e-10)) math.log(self.emit_p[y].get(text[0], 1e-10)) path[y] [y] # 递推 for t in range(1, len(text)): V.append({}) new_path {} for y in self.state_list: emit_p math.log(self.emit_p[y].get(text[t], 1e-10)) (prob, state) max( [(V[t-1][y0] math.log(self.trans_p[y0].get(y, 1e-10)) emit_p, y0) for y0 in self.state_list]) V[t][y] prob new_path[y] path[state] [y] path new_path # 终止 (prob, state) max([(V[len(text)-1][y], y) for y in self.state_list]) return (prob, path[state])5. 分词结果生成得到最优状态序列后我们需要将其转换为实际的分词结果def cut(self, text): if not self.trained: raise ValueError(Model not trained or loaded) _, pos_list self.__viterbi(text) begin, next_pos 0, 0 for i, char in enumerate(text): pos pos_list[i] if pos B: begin i elif pos E: yield text[begin:i1] next_pos i1 elif pos S: yield char next_pos i1 if next_pos len(text): yield text[next_pos:]6. 工程优化与实用技巧在实际应用中我们还需要考虑一些工程优化6.1 未登录词处理对于训练集中未出现的字符可以采用以下策略统一赋予一个小的默认概率值根据字符类型(数字、字母、标点等)分配不同概率使用字符的n-gram特征def get_emit_prob(self, state, char): if char in self.emit_p[state]: return self.emit_p[state][char] # 未登录词处理 if char.isdigit(): return 1e-6 # 数字的默认概率 elif char.isalpha(): return 1e-7 # 字母的默认概率 else: return 1e-8 # 其他字符的默认概率6.2 性能优化对于大规模文本分词可以考虑以下优化使用Cython或Numba加速Python代码实现批处理接口减少IO开销使用多进程并行分词def batch_cut(self, texts): with Pool(processes4) as pool: results pool.map(self.cut, texts) return results7. 模型评估与改进完成实现后我们需要评估分词器的性能。常用的评估指标包括指标计算公式说明准确率(正确切分词数)/(系统切分词数)系统输出的正确率召回率(正确切分词数)/(标准切分词数)系统发现的能力F1值2准确率召回率/(准确率召回率)综合评估指标在实践中可以通过以下方式改进模型增加更多样化的训练数据引入词典特征结合规则方法处理特定领域词汇尝试更复杂的模型如CRF、BiLSTM-CRF等def evaluate(self, test_file): total_pred 0 total_gold 0 correct 0 with open(test_file, r, encodingutf-8) as f: for line in f: line line.strip() if not line: continue # 标准分词结果 gold_words [w for w in line.split() if w] total_gold len(gold_words) # 模型预测结果 text .join(gold_words) pred_words list(self.cut(text)) total_pred len(pred_words) # 统计正确数 gold_set set((i, ilen(w)) for i, w in enumerate(gold_words)) pred_set set((i, ilen(w)) for i, w in enumerate(pred_words)) correct len(gold_set pred_set) precision correct / total_pred recall correct / total_gold f1 2 * precision * recall / (precision recall) return {precision: precision, recall: recall, f1: f1}实现一个完整的HMM分词器需要考虑的细节远比理论推导复杂。在实际项目中我发现对数概率转换和未登录词处理对最终效果影响最大。另外适当调整平滑系数也能带来明显的性能提升。

更多文章