尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

PyTorch实战:BiLSTM-CRF中文分词完整工程解析

PyTorch实战:BiLSTM-CRF中文分词完整工程解析 简介这是一套使用PyTorch深度学习框架实现的BiLSTM-CRF中文分词系统项目包面向对自然语言处理、序列标注和中文分词感兴趣的开发者适合作为课程设计或入门实践的完整参考。压缩包共包含8个文件其中5个Python脚本覆盖配置参数、数据预处理、模型定义、训练与测试全流程2个训练好的模型文件可直接加载使用另有1个说明文档用于辅助快速上手整体体积仅23KB轻量易部署。目前已有108人学习下载。项目通过双向LSTM捕捉上下文语义并借助条件随机场优化标签序列兼顾局部特征与全局约束同时提供分词结果的评估与测试脚本方便读者验证准确率、召回率和F1分数。下载后可按脚本依赖关系依次运行快速复现完整的中文分词流程并在此基础之上替换数据集进行迁移尝试是理解BiLSTM-CRF原理与PyTorch工程实现的实用资源。1. 中文分词为什么值得用BiLSTM-CRF中文句子没有空格分词是所有下游任务的起点。基于词的CNN/Transformer模型很多但分词本身最适合按序列标注来做。BiLSTM-CRF先让双向LSTM把字符上下文编码成向量再用CRF把标签间的约束关系全局解码这个组合在过去这些年里一直是实用度和可控性都很高的基线方案。你拿到的这个压缩包里包含的不只是模型而是一套从数据预处理、训练到测试的完整工程data_process.py负责语料转索引trainning.py跑训练test_model.py做推理还有直接可加载的cws_all.model和cws.model。对想快速跑通PyTorch分词项目的人来说这是一条能直接复现的路径而不是一段纸面代码。2. 从LSTM到BiLSTM再到CRF分词背后的序列建模理论2.1 标签体系的选择BMES中文分词常用B/M/E/S四个标签B表示词首M表示词中E表示词尾S表示单字成词。模型对每个字符输出一个标签再按标签切分。例如“今天天气好”会被标为“今/B 天/E 天/B 气/E 好/S”。这里可能有人会问为什么不用“词/非词”二分类因为二分类无法表达词的长度信息“单字成词”和“词首”都会混在一起模型很难学。BMES把每个词的结构显式展开实际上是把“切词组”问题转换成了“每个字符的身份”问题这也是分词最标准的pipeline。这里涉及一个容易搞错的地方输入是字符级还是词级。分词任务本身要切词所以输入必须是字符级否则等于先泄露了答案。data_process.py里通常会把原始句子拆成单个字符并对应生成等长的标签序列。同时要注意padding时对标签也要padding而且padding位置要设置ignore_index否则CRF的转移矩阵会把无效位置也学进去模型训练出来会出现“从padding标签跳到B”这种明显错误。2.2 BiLSTM如何编码字符上下文LSTM通过门控结构缓解了长依赖问题但单向LSTM只能看到过去的信息。中文分词里一个词是否成词往往依赖后文比如“研究”和“研究生”只看左边很难判断。BiLSTM把正向和反向两趟LSTM的输出在时间步上拼接起来形成一个同时包含前后文的字符向量。在中文场景中双向编码比单向LSTM带来的提升通常能高2-3个F1点所以几乎没有人会退回去用单向。在PyTorch中实现BiLSTM编码层一般这样写import torch import torch.nn as nn class BiLSTMEncoder(nn.Module): def __init__(self, vocab_size, embed_size, hidden_size, num_layers1, dropout0.5): super().__init__() # padding_idx0 让pad对应的嵌入向量始终为零 self.embedding nn.Embedding(vocab_size, embed_size, padding_idx0) self.lstm nn.LSTM( input_sizeembed_size, hidden_sizehidden_size, num_layersnum_layers, bidirectionalTrue, batch_firstTrue, dropoutdropout if num_layers 1 else 0 ) self.dropout nn.Dropout(dropout) def forward(self, input_ids, lengths): emb self.dropout(self.embedding(input_ids)) # 打包变长序列避免padding参与LSTM运算 packed nn.utils.rnn.pack_padded_sequence( emb, lengths.cpu(), batch_firstTrue, enforce_sortedFalse ) packed_out, _ self.lstm(packed) out, _ nn.utils.rnn.pad_packed_sequence(packed_out, batch_firstTrue) return out这里embed_size是字符向量维度hidden_size是单向LSTM的隐藏维度双向encoder输出的最后一维是hidden_size * 2。lengths是每个句子实际的字符长度用pack_padded_sequence打包是为了让padding位置不参与LSTM计算否则padding会持续产生隐藏状态干扰序列边界。等CRF层输入时需要先把输出维度从hidden_size * 2线性映射到标签数。2.3 CRF层为什么比逐点Softmax强如果只在每个字符位置上独立Softmax模型很容易选出相邻位置互相冲突的标签比如“B”后面直接跟“S”或者“M”出现在“B”之前。CRF层引入一个转移矩阵显式建模从当前标签跳到下一个标签的得分。转移矩阵的形状是tag_size x tag_size代表所有相邻标签对的转移分数在BMES之外还需要额外增加START和END两个虚拟标签用来约束序列头尾。训练时使用负对数似然作为损失对每个样本计算所有可能的标签路径的概率之和再做normalization。这个机制并不难但在PyTorch里手动实现CRF前向计算需要写对数域计算避免直接拿exp做归一化导致数值上溢。常见做法是先写一个安全的log-sum-expdef log_sum_exp(vec, dim-1): # 减去最大值防止exp上溢 m, _ vec.max(dim, keepdimTrue) return m.squeeze(dim) torch.log(torch.exp(vec - m).sum(dim))这样计算所有路径得分时先减掉当前最大值再把结果做指数求和最后加回最大值数值稳定性会好很多。解码时用维特比算法搜索整条序列的最优标签路径而不是取每个位置的最大值这样能让输出标签序列在语法上自洽。维特比复杂度是O(T*L^2)T为序列长度L为标签数BMES加上虚拟标签也就6个所以计算开销可以忽略。标签含义典型示例B词首字符“中国”中的“中”M词中字符“巧克力”中的“克”E词尾字符“中国”中的“国”S单字成词“好”单用时上面这个表在训练脚本里会被转成label2idx对应关系必须和data_process.py保持一致否则训练出的cws.model在测试时会出现标签错位。很多看起来“模型坏了”的现象其实都是这里没对齐。3. 数据预处理与config配置决定模型上限的第一步3.1 原始语料到字符索引分词训练通常读入的是“字符 标签”按空格分隔的序列。data_process.py的常见做法是维护一个字符到ID的映射表比如{pad:0, 中:1, 文:2, ...}。对每个句子把它拆成字符用映射表转成整数序列同时生成标签序列。这块的坑主要在编码Python的str索引一个中文字符没问题但要注意标注语料里是否混有全角空格或换行符这些符号会变成无意义的字符把标签序列撑长。可以参考下面这段代码def build_vocab(sentences): char2idx {pad: 0, unk: 1} for sent in sentences: for ch in sent: if ch not in char2idx: char2idx[ch] len(char2idx) idx2char {i: ch for ch, i in char2idx.items()} return char2idx, idx2char这段代码为字符建立索引pad和unk固定在0、1两个位置。实际训练时遇到没见过的字符用unk代替这能避免模型在测试时因为输入OOV索引越界。这里补充一句pad的嵌入向量在初始化时会被置零这样padding位置经过Embedding后是零向量对LSTM的影响最小。3.2 标签对齐与序列补齐一个batch里的句子长度不同需要用padding补齐到同一长度。字符序列和标签序列要同步padding。PyTorch的nn.utils.rnn.pad_sequence可以帮你做这件事但要把标签的padding值设为-100或其它忽略值这样在计算CRF损失时才能用mask过滤掉padding位置。常见做法是训练时维护一个lengths列表记录每个样本真实长度然后调用pack_padded_sequence。这里要特别注意顺序如果enforce_sortedTrue需要先按长度降序排列batch再打包如果设成FalsePyTorch会自动排序但会多一次拷贝速度略慢。batch_firstTrue则保证输入和输出的形状是batch_size x seq_len x hidden这样后续线性层和CRF的配合不用调整维度顺序。3.3 config.py里的关键参数config.py存有模型超参数和路径配置。典型字段如下表参数名典型值作用vocab_size3000字符表大小和char2idx一致embed_size128字符嵌入维度hidden_size256BiLSTM单向隐含层维度batch_size64训练batch大小lr0.001初始学习率max_seq_len200超过此长度的句子会被截断model_pathcws.model训练完成后的模型保存路径这个表里的hidden_size决定了BiLSTM输出维度是hidden_size * 2也直接决定CRF前接线性层的输入维度。max_seq_len设太小会切断长句导致分词不完整设太大则GPU显存占用高。我一般先用短文本调通再逐步增大到256或300等模型过拟合时再配合dropout控制方差。3.4 word.txt作为外部词典参与后处理word.txt里通常每一行是一个词比如“自然语言”“处理”。训练时可以用它做词典特征也可以在预测阶段做规则修正。常见做法是先跑一遍BiLSTM-CRF得到B/M/E/S序列然后用词典里长度大于2的词在原始文本中做最长匹配如果匹配结果和模型结果冲突优先保留词典词。这种混合方式对专业领域术语很有帮助因为训练语料里很多低频词没学到。需要注意后处理规则必须放在解码之后、输出之前。如果训练和测试都走同样的后处理模型本身已经足够强规则只是兜底如果只在测试时使用训练时模型并不知道词典存在个别词可能被拆开再被规则强行合并但不会伤害整体效果。在代码里实现时可以用word.txt构建一个Trie树把匹配到的区间直接替换标签序列复杂度O(文本长度)可接受。4. 训练与测试从零跑通BiLSTM-CRF4.1 组装模型BiLSTMEncoder CRFDecoderBiLSTM_CRF.py里通常会定义一个BiLSTMCRF类。forward函数里先过Embedding和BiLSTM再用线性层把输出映射到标签空间最后交给CRF层。需要实现两个计算方向训练时返回negative_log_likelihood预测时返回decode得到的标签序列。代码示意class BiLSTMCRF(nn.Module): def __init__(self, char2idx, tag2idx, embed_size, hidden_size): super().__init__() # padding_idx0 固定pad的嵌入向量 self.embedding nn.Embedding(len(char2idx), embed_size, padding_idx0) self.lstm nn.LSTM(embed_size, hidden_size, bidirectionalTrue, batch_firstTrue) self.hidden2tag nn.Linear(hidden_size * 2, len(tag2idx)) self.crf CRF(len(tag2idx)) def forward(self, sentences, tagsNone, maskNone, modetrain): emb self.embedding(sentences) lstm_out, _ self.lstm(emb) emissions self.hidden2tag(lstm_out) if mode train: return self.crf.negative_log_likelihood(emissions, tags, mask) else: return self.crf.decode(emissions, mask)这里CRF类需要自己实现维特比和log-sum-exp。mask用来屏蔽padding位置CRF在转移计算时不会让padding位置参与标签转移这样训练更稳定。训练时tags由data loader提供形状和emissions一样预测时tags传None走decode分支。4.2 训练循环与损失函数训练脚本trainning.py的核心逻辑是迭代数据、清空梯度、前向、计算损失、反向、更新优化器。常用Adam优化器学习率设置成0.001或更低。因为CRF损失返回的是负对数似然数值上一般比交叉熵要小但不需要额外处理。optimizer torch.optim.Adam(model.parameters(), lrconfig.lr) for epoch in range(config.epochs): for batch_data, batch_tags, batch_lengths in train_loader: # 根据长度生成bool maskpadding位置为False mask make_padding_mask(batch_lengths) loss model(batch_data, batch_tags, mask) loss.backward() optimizer.step() optimizer.zero_grad()调用loss.backward()前先把上一步梯度清零否则PyTorch会把多个batch的梯度累加。make_padding_mask生成一个形状为batch_size x max_len的bool mask供CRF内部使用。如果你的训练集很小可以先把batch_size设小一点否则模型在早期会学到大量边界噪声CRF转移矩阵容易出现“B-S”的诡异跳转。4.3 模型保存与测试训练结束后会保存两种文件cws.model可能只存模型参数cws_all.model可能是连char2idx、tag2idx、超参数一起打包的完整模型。加载时建议用torch.load的weights_onlyFalsePyTorch 2.x版本来恢复否则默认安全限制会拒绝加载非张量对象。下面这段来自test_model.py的预测逻辑model BiLSTMCRF(char2idx, tag2idx, config.embed_size, config.hidden_size) model.load_state_dict(torch.load(config.model_path)) model.eval() test_text 我喜欢使用pytorch做中文分词 input_ids torch.tensor( [char2idx.get(ch, char2idx[unk]) for ch in test_text] ).unsqueeze(0) mask torch.ones_like(input_ids, dtypetorch.bool) with torch.no_grad(): tags model(input_ids, modepredict, maskmask)[0]这里input_ids.unsqueeze(0)是为了增加batch维度因为模型要求输入形状为batch_size x seq_len。mask全为True说明没有padding。model.eval()关闭Dropout而torch.no_grad()则关闭Autograd追踪省显存。输出的tags是长度为seq_len的整数列表用tag2idx反向映射即可得到B/M/E/S标签。文件作用data_process.py语料读取、字符索引构建、数据loaderconfig.py超参数与路径配置BiLSTM_CRF.py模型结构定义trainning.py训练入口test_model.py加载模型做推理cws.model训练得到的参数权重cws_all.model带配置信息的模型word.txt可选外部词表这个表里的word.txt你在代码里可能会看到被读入词典但它不是训练时必需的。很多实现是用它来做后处理修正的比如识别出词典里的大词在CRF结果里强制合并。4.4 评估不要只看准确率分词质量一般用P/R/F1而不是字符级准确率。准确率会被大量单字词拉高看起来99%但实际分错很多。F1计算需要把预测标签还原成词序列再与标准词序列做集合匹配。推荐用seqeval库它直接支持BIO/BMES标签计算。from seqeval.metrics import f1_score, precision_score, recall_score y_true [[中国], [研究生]] y_pred [[中国], [研究, 生]] print(f1_score(y_true, y_pred))这里需要注意的是y_true和y_pred是词列表的列表不是标签列表。训练时我们验证的是标签但最终指标是词级别的F1两者可能相关但有差异。标签正确不一定分词边界正确比如“研究生”标成“研究|生”标签是“研/B 究/M 生/S”逐点准确率可能高但词错了。5. 进阶加载cws_all.model做batch分词与常见坑5.1 批量推理避免循环切分如果要对大量文本分词逐条调用模型会非常慢。正确做法是把多条文本pad成一个batch一次性跑。这里有个技巧按长度排序然后分组减少padding空间。在PyTorch里可以用collate_fn在dataloader中完成也可以直接调pad_sequence。def predict_batch(model, texts, char2idx, max_len200): texts [t[:max_len] for t in texts] input_ids [] for text in texts: # 未登录字统一映射到unk ids [char2idx.get(ch, char2idx[unk]) for ch in text] input_ids.append(torch.tensor(ids, dtypetorch.long)) padded torch.nn.utils.rnn.pad_sequence( input_ids, batch_firstTrue, padding_value0 ) mask (padded ! 0) with torch.no_grad(): tags model(padded, modepredict, maskmask) return texts, tags注意mask的padding位置要置为False这样CRF的解码会跳过这些位置。pad_sequence默认按batch中最大长度补齐如果输入都是短文本显存占用不会一下子涨上去。得到tags后按每条的原始长度截断再用BMES规则把字拼成词。5.2 未登录词和边界问题模型对训练语料没出现过的字会统一映射为unk这会导致未知字周围的分词不稳定。改进办法有两个一个是把外部词表比如word.txt里的词加入后处理约束另一个是训练时做字符随机替换增强模型对罕见字的鲁棒性。在PyTorch里随机替换可以在data loader中抛出10%的字符为unk来模拟。还有一个容易踩的坑CRF输出后还原成词的时候遇到连续两个B的非法序列。虽然CRF转移矩阵会尽量阻止这种输出但在训练不充分时也可能出现。后处理时从右到左扫描把后一个B改成M或者直接按“单字成词”降级可以避免分词结果里出现孤零零的半个词。另外pytorch安装时注意CPU/GPU版本和项目里torch.load的兼容性很多报错其实是libtorch版本不一致导致的反序列化失败。本文还有配套的精品资源点击获取
返回列表