第一次跑通BiLSTM+CRF中文分词模型的时候,我手里的代码也就几百行,但那一周几乎每天睡前都在反复看“状态转移”“发射得分”“维特比回溯”这些概念。中文分词(CWS)是自然语言处理里最基础也最容易被忽略的环节,搜一次、翻译一句、输入法联想一下,背后几乎都有分词器在工作;而BiLSTM+CRF是深度序列标注时代最经典的分词方案,代码量不大、原理清晰、可复现性高,特别适合入门NLP建模的人、准备面试的后端工程师,以及想给自己系统加一个“分词模块”的开发者。这篇我会把“为什么用这个方案→怎么建模→模型每一层在做什么→代码怎么实现→踩过哪些坑”整个链路讲透,关键代码全部带注释。
1. 为什么中文分词绕不开BiLSTM+CRF
1.1 中文分词难在哪里
中文和英文不同,词与词之间没有天然空格。句子“研究生命起源”至少有两种合法切法:“研究/生命/起源”和“研究生/命/起/源”,人靠着对上下文的直觉能判断前面那种读起来更自然,但这直觉背后涉及句法、语义甚至百科知识,机器要学出这套判断并不容易。另一个例子是“乒乓球拍卖完了”,“球场上的拍子卖光了”是一种切法,“球拍拍卖活动结束了”又是另一种切法。
分词难主要集中在三个层面。歧义切分是第一个,典型的有交集型歧义和组合型歧义。未登录词是第二个,每年都会有新词出现,比如某些名称、缩写、专业术语,词典方法遇到这些词几乎必挂。第三个是边界认定主观,同一个词在不同语料规范里可能有不同切法,甚至评测算分时不同标注标准会直接影响最终数值。这就决定了单纯靠词典或者规则很难做到通用,必须有能从数据中远距离学习上下文规律的方法。
1.2 从词典匹配到深度序列标注的三代方案
最早的分词器基本都是词典匹配,最经典的是正向最大匹配、逆向最大匹配、双向最大匹配。思路很简单:拿着一本词表去句子里从长到短切成词。优点是快,几十万词表跑得飞快;缺点是歧义靠规则兜不住,新词更是天然盲区。
第二代是统计机器学习方案,最典型的是HMM和CRF。HMM把分词看成字级别序列标注,用转移概率描述相邻标签之间的关系;CRF则更进一步,不再做观测独立性假设,能用全局特征函数刻画上下文条件概率。在深度学习还没普及的年代,CRF是分词和命名实体识别的主流,直到今天CRF层仍然在序列标注模型中扮演收官角色,这就是为什么你在很多现代架构里还能看到CRF的影子。
第三代就是深度学习方案。BiLSTM长短期记忆网络能自动从大规模语料里学出上下文表示,不再像CRF那样需要人工设计几十上百个特征模板,分词任务被统一成“每个字打标签”的序列标注问题。而BiLSTM+CRF之所以经典,是因为它把深度学习自动提取特征的能力和CRF结构化预测的能力接在了一起:BiLSTM负责“看上下文”,CRF负责“保证标签序列合法”。这套组合在十几行代码内就能实现,效果却能吊打手工特征时代的模型,所以直到BERT普及之前,它一直是中文分词、命名实体识别等序列标注任务的标准结构。
2. 建模第一步:把分词任务转换成序列标注
2.1 用B/I/E/S标签表达词的边界
要让神经网络处理分词,第一步是把切分问题改写成“给每个字分配标签”的问题。我习惯用四标签体系B、I、E、S,含义分别是:B代表词首字,I代表词中间字,E代表词尾字,S代表单字成词。严格来说也可以用BIO三标签,即在四标签基础上去掉I和E的区分,但四标签能给CRF更明确的边界信号,训练收敛更稳定。
举例说明。“我去公园”这五个字,正确的切分是“我/S 去/S 公园/B E”。一个词只有一个字时打S,有两个字时打“B E”,三个字及以上打“B I ... I E”。这样分词就变成了一个标准的分类问题:每进来一个汉字,模型预测它是B/I/E/S中的哪一个。把句子所有标签连出来之后,按B开头、E或S结尾的规则切分即可还原成词序列。这也是为什么后面对接CRF时天然合适,因为标签之间的转移规律非常清晰:I不能出现在句首,B后面不能直接跟S,E后面不能直接跟I,这些约束就是结构信息。
2.2 语料预处理流程:字表、标签表与padding
训练数据的预处理大概分五步。第一步是拿到已经按词切好的一句话,把每个词按B/I/E/S展开成字级别的标签序列。第二步统计所有字,构造字表vocab,一般把PAD放在index 0,UNK放在index 1。这里有个细节:不管你用了什么预训练字向量,UNK这个位置是必须预留的,否则模型推理时遇到训练集没有出现过的字就没办法查表。
第三步是构造标签表,我习惯按“B:0 I:1 E:2 S:3”这样排序,注意CRF转移矩阵的维度是num_tags乘num_tags,标签索引一旦定下来后面就不能随意改。第四步是统一句子长度。Batch内句子长短不一,所以要把短句补到相同长度,同时记录每条样本的真实长度,供后面mask和pack使用。第五步是把“字ID序列”和“标签ID序列”组装成Tensor,交给训练循环。如果你用的框架有自己的DataLoader,这一步还要写一个collate函数处理动态padding,很多新手在batch训练时发现训练状态全是对的、但loss是nan,排查下来基本都是这一步没处理好。
2.3 词向量的选择逻辑
这里存在一个被反复问起的点:字向量是随机初始化还是用预训练?我的建议要分情况。数据量少的场景(比如只有几万条标注句),随机初始化字向量配较大embedding_dim反而容易过拟合,不如直接加载通用预训练字向量稳;数据量足够大的场景,随机初始化效果并不差,因为BiLSTM结构本身会根据下游任务对字向量做大量调整。分词是序列级任务,字特征会随着训练语料的变化被充分重排,所以不必执着于用词向量。
维度方面,常见经验是100到200。太大容易让Embedding层参数爆炸,太小又表达不了足够语义。这里有个工程考量:BiLSTM输入维度直接等于embedding_dim,如果同时想在训练阶段把字向量冻结、只更新BiLSTM部分,建议把它和整个模型一起训练,效果通常更好。我在实际项目中试过只固定预训练向量,结果loss确实能降但F1基本涨不动,后来改成“预训练初始化,微调训练”才正常。业内把这种现象叫域漂移:预训练向量来自通用语料,如果目标语料是特定领域,比如医疗、法律文本,字义变化会很大,必须让模型自己去适配下游数据。
3. 模型架构逐层拆解:从字向量到最优标签路径
3.1 BiLSTM如何提取上下文特征
LSTM给NLP带来的核心能力是解决长距离依赖。早期语言模型常用固定的窗口上下文,但窗口小了看不全,大了特征爆炸。LSTM通过三个门的机制让信息在序列中流动,“记忆细胞”可以保留距离较远的关键词,对当前字符的判断产生持续影响。BiLSTM就是把这个过程正向和反向各跑一遍,正向LSTM捕捉每个字左边的上下文,反向LSTM捕捉右边的上下文,两个方向的隐状态拼接起来,构成当前字符的完整上下文表示。
为什么分词一定需要双向?词边界判定天然依赖左右两侧的信息。“南京市长江大桥”要判断“长江”是不是一个完整词,不只取决于它自己,还取决于“南京”、“大桥”,只有把双向信息拼接起来,模型才能同时感知前后语境的影响。BiLSTM在数学上和普通LSTM完全一样,唯一区别是输入序列沿两个方向分别处理,输出时把维度拼在一起。因为两个方向的隐状态各自是hidden_dim维,常见实现里LSTM的隐层参数通常会设为最终想要维度的一半,拼起来正好等于hidden_dim,这样下游线性层不用改维度。
3.2 CRF层在做什么:发射得分之外还要管转移约束
BiLSTM每个时刻的输出经过一层线性映射,会得到当前汉字对应四个标签的发射得分(emission score),就是这个字是B/I/E/S分别有多大的置信度。如果直接在这些得分上取argmax或做softmax分类,会出现一个非常尴尬的现象:预测结果里可能出现“I”开头的句子、可能出现“E后面接I”的非法组合。原因是每类标签之间的依赖被忽略了,每个位置都被当成独立分类来解。
CRF层解决的就是这个问题。它引入一个可学习的标签转移矩阵,形状是num_tags乘num_tags,其中第i行第j列代表“从标签i转到标签j”的代价/得分。模型在训练时会自己学到“B后面通常接I或E,不能接S;I后面通常接E,不能直接接B;E后面通常接B或S”等转移规则。如果转移矩阵里某个非法转移的得分非常低,或者你干脆初始化时手动把非法转移设成一个大负数,那模型输出非法序列的概率就会被压到极低。CRF层的存在让分词结果在结构化上是合法的,不再是一堆单点预测拼起来。
3.3 路径得分与维特比:训练和预测为什么需要两套算法
把一句话的所有可能标签序列列出来,每一条路径都有一个总得分,CRF就是这个得分模型。某条路径的总得分等于每个位置的发射得分加上所有相邻位置的转移得分之和,同时还要算上“从START进入第一个标签”和“从最后一个标签转入STOP”的两个转移项。训练时模型要最大化正确路径的得分占所有路径得分总和的比重,这个目标等价于最大化正确标签路径的对数概率。
难点在于,所有路径的数量是标签数的序列长度次方,不可能穷举。训练时要算“所有路径得分之和的对数”,就用前向算法动态规划:按位置逐步累积每个标签的logsumexp值,复杂度是O(序列长度乘标签数平方)。预测时要求“得分最高的一条合法路径”,用维特比算法:每一帧保留到当前标签为止的最大分数和对应上一步标签,最后从结尾回溯得到完整标签序列。前向和维特比是两个高效动态规划,本质是一回事:一个求总和,一个求最大值。我在初学阶段经常混淆,踩坑的记忆很深刻,后文代码部分会详细展开。
4. PyTorch版本的核心代码实现与注释
4.1 工程设计:三个模块怎么拆分
代码层面我建议分成三块:数据处理模块负责把语料转成Tensor,模型模块负责实现BiLSTM+CRF,训练模块负责循环迭代和模型保存。不要把逻辑全塞在一个文件里,初期可能觉得方便,后面想调参、想换数据集时会非常痛苦。我这里给出的模型代码是核心抽取片段,如果你在实际项目中使用,建议把它封装到model.py里,配合训练脚本一起运行。
我写的实现有一个关键假设:使用B/I/E/S四标签,batch内按相同长度补齐,训练时传入的长度数组用来构造mask,保证padding位置不参与损失计算。
需要注意代码里的forward方法并不直接返回损失,而是分成“计算分数”和“计算损失”两个方法。这样训练和预测都能复用相同的特征提取逻辑,避免模型跑一次;另外调用预测时不需要算CRF损失,因为不需要对分母做全局归一化,直接维特比即可。
4.2 BiLSTMCRF模型类的构造与特征提取
下面这段是模型主体框架。我把所有参数都放在__init__里,方便后续调参:
class BiLSTMCRF(nn.Module): def __init__(self, vocab_size, embedding_dim, hidden_dim, num_tags, num_layers=1, dropout=0.5): super().__init__() # 字向量层:vocab_size是词表大小,embedding_dim是字向量维度 self.embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx=0) # 双向LSTM:输入是字向量,输出维度是hidden_dim # 因为双向会左右各产生hidden_dim // 2维,拼起来正好是hidden_dim self.bilstm = nn.LSTM( embedding_dim, hidden_dim // 2, num_layers=num_layers, batch_first=True, bidirectional=True, dropout=dropout if num_layers > 1 else 0, ) # 防止过拟合 self.dropout = nn.Dropout(dropout) # 将BiLSTM输出映射到每个标签的发射得分 self.fc = nn.Linear(hidden_dim, num_tags) # 下面这三个是CRF的转移参数 # transitions[i][j]表示从标签i转移到标签j的得分 self.transitions = nn.Parameter(torch.randn(num_tags, num_tags) * 0.01) # 序列开始前进入第一个标签的得分 self.start_trans = nn.Parameter(torch.randn(num_tags) * 0.01) # 最后一个标签转移到序列结束的得分 self.end_trans = nn.Parameter(torch.randn(num_tags) * 0.01)这里有一个很多人会写成默认参数然后踩坑的地方:bidirectional=True配合hidden_dim // 2是在工程细节上为了保证拼接后维度正好等于hidden_dim。如果你把LSTM的单向隐层直接设为hidden_dim,拼接后就是2 * hidden_dim,后面self.fc = nn.Linear(2 * hidden_dim, num_tags),两种写法都可以,但参数初始化scale和后续网络宽度都会受影响,需要保持一致性。
特征提取部分:
def compute_logits(self, token_ids): # token_ids: (batch, seq_len) embedding = self.embedding(token_ids) # (batch, seq_len, embedding_dim) lstm_out, _ = self.bilstm(embedding) # (batch, seq_len, hidden_dim) lstm_out = self.dropout(lstm_out) logits = self.fc(lstm_out) # (batch, seq_len, num_tags) return logits这里我刻意没有传入lengths,而是用完整序列直接过LSTM。理论上可以使用pack_padded_sequence提高效率并避免padding对LSTM状态产生干扰,但对教学代码来说,显式构造mask反而更透明。如果你想在生产环境提速,可以在compute_logits里包一层pack,之后再pad回原形状。
4.3 CRF损失计算:黄金路径得分与全局归一化
CRF损失的核心是“黄金路径得分”减去“所有路径得分之和的对数”。先算黄金路径得分:
def calc_gold_score(self, logits, tags, mask): batch_size, seq_len, num_tags = logits.size() score = torch.zeros(batch_size, device=logits.device) # 初始转移:从START到第一个标签 # gather按索引取值 score += self.start_trans.gather(0, tags[:, 0]) # 累加每一个位置的发射得分 # logits[b, t, tags[b, t]] score += logits.gather(2, tags.unsqueeze(2)).squeeze(2).masked_fill(~mask, 0).sum(dim=1) # 累加相邻位置的转移得分 for t in range(seq_len - 1): # 只有连续两个位置都有效时才累加转移 transition = self.transitions[tags[:, t], tags[:, t + 1]] # 用mask确保padding位置不参与 transition = transition.masked_fill(~mask[:, t + 1], 0) score += transition # 最后从最后一个有效标签转移到STOP # 这里需要用mask找出每个样本最后一个有效位置 last_idx = mask.sum(dim=1) - 1 last_tags = tags.gather(1, last_idx.unsqueeze(1)).squeeze(1) score += self.end_trans.gather(0, last_tags) return score为什么这里要masked_fill?因为padding位置的发射分数如果不mask掉,会被当作真实标签来累加,直接污染损失。这个点非常容易被初学者忽略:padding赋成标签0,结果黄金路径得分多算了一大段,all路径得分也受影响,最终损失全是噪声。我在调试早期版本时,就因为在softmax上没做mask,F1一直卡在40%左右,后来一行masked_fill修好直接跳到接近90%,这个教训印象极深。
所有路径得分之和的对数用前向算法计算:
def calc_all_path_score(self, logits, mask): batch_size, seq_len, num_tags = logits.size() # 初始化:每个位置的第一个标签从START转移过来 alpha = self.start_trans.unsqueeze(0).expand(batch_size, num_tags) + logits[:, 0] for t in range(1, seq_len): # 先算当前可以转移到本位置的所有来源分数 # alpha.unsqueeze(2)形状(batch, num_tags, 1) # transitions.unsqueeze(0)形状(1, num_tags, num_tags) # 取logsumexp得到下一时间步的alpha next_alpha = torch.logsumexp( alpha.unsqueeze(2) + self.transitions.unsqueeze(0), dim=1, ) + logits[:, t] # 如果是padding位置,保持上一时刻的alpha不变 # 这样下一帧不会继续累计padding路径 alpha = torch.where(mask[:, t].unsqueeze(1), next_alpha, alpha) # 最后加上转移到STOP的得分,做一次logsumexp得到所有路径总得分 all_scores = torch.logsumexp(alpha + self.end_trans.unsqueeze(0), dim=1) return all_scores这里有三个常见问题。第一,为什么不把padding位置从时间步开头就截断?因为动态规划要求序列长度统一,前向过程跑完整序列后,padding位置的影响必须通过torch.where抑制掉。第二,为什么padding位置alpha保持不变?因为后续真实位置的路径得分不应该被padding位置的logits覆盖,这是CRF实现里最容易写错的一行。第三,为什么最后要用logsumexp而不是直接对alpha求和?因为我们把所有合法路径都转化为不同终态标签,转移到STOP再聚合所有终态,本质上求的是一个全局归一化常数,logsumexp是数值稳定的做法。
有了gold_score和all_score,训练时的损失就是:
def forward_loss(self, token_ids, tags, mask): logits = self.compute_logits(token_ids) gold_score = self.calc_gold_score(logits, tags, mask) all_score = self.calc_all_path_score(logits, mask) loss = (all_score - gold_score).mean() return loss注意这里返回的是负对数似然,所以是all_score减去gold_score取均值。PyTorch的autograd会自动对参数求导,CRF训练中没有额外正则项,因为dropout和参数初始化已经提供了通用正则能力。
4.4 维特比解码的纯PyTorch实现
预测的时候没有真实标签,我们要找到总分最高的一条路径。维特比解码的核心是一个回溯表,记录每一时间步上每个标签的最优前驱标签,最后从结尾回溯整条路径:
def decode(self, token_ids, mask): batch_size, seq_len, num_tags = logits.size() logits = self.compute_logits(token_ids) # dp[b][t][j]的含义:到第t步为止,以标签j结束的最高累计得分 dp = self.start_trans.unsqueeze(0).expand(batch_size, num_tags) + logits[:, 0] # 回溯表:back_pointer[b][t][j] = 上一时间步的最优前驱标签 back_pointer = [] for t in range(1, seq_len): # scores形状(batch, 当前标签数量, 上一标签数量) # [b, j, i] = dp[b][i] + transitions[i][j] + logits[b][t][j] scores = dp.unsqueeze(2) + self.transitions.unsqueeze(0) + logits[:, t].unsqueeze(1) # 每个当前标签的最大来源标签 best_scores, best_tags = scores.max(dim=1) # best_tags: (batch, num_tags) dp = best_scores back_pointer.append(best_tags) # padding位置不更新,保证后续不受padding影响 dp = torch.where(mask[:, t].unsqueeze(1), dp, dp_from_prev) # 合并转到STOP dp = dp + self.end_trans.unsqueeze(0) # 最后一个有效位置的标签 last_pos = mask.sum(dim=1) - 1 # 得到每个样本最优终态标签 best_last_tag = dp.argmax(dim=1) # 从后往前回溯 path = [best_last_tag] for b_pointer in reversed(back_pointer): previous_tag = b_pointer.gather( 1, best_last_tag.unsqueeze(1) ).squeeze(1) path.append(previous_tag) best_last_tag = previous_tag # path是逆序的,转回正序,并只保留有效长度 path = torch.stack(path[::-1], dim=1) return path这段代码在写之前要理解清楚:为什么回溯时要gather上一步的back_pointer?因为back_pointer[t][j]存的是“如果当前位置是标签j,那么上一位置最优的标签是哪个”。我们确定当前标签后,顺着回溯表往前一层一层还原即可。另外注意路径堆叠后包含padding位置,使用mask.sum获取的有效长度把它截掉。
5. 训练流程、超参经验与效果评测
5.1 训练循环怎么写:optimizer、loss与梯度裁剪
训练部分的核心很简单,关键点是把mask构造、loss计算、梯度裁剪和模型保存串起来:
model = BiLSTMCRF(vocab_size=len(vocab), embedding_dim=100, hidden_dim=200, num_tags=4) optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode="max", factor=0.5, patience=2) for epoch in range(EPOCHS): model.train() total_loss = 0.0 for batch in train_loader: token_ids, tags, mask = batch loss = model.forward_loss(token_ids, tags, mask) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) optimizer.step() total_loss += loss.item() # 验证集F1,做早停或学习率衰减 f1 = evaluate(model, valid_loader) scheduler.step(f1) if f1 > best_f1: torch.save(model.state_dict(), "best_model.pt")梯度裁剪是我强烈建议加的一行。LSTM类模型在长句子训练中很容易梯度爆炸,clip_grad_norm_设置成5.0可以显著提升稳定性。训练中期如果发现loss出现nan,优先检查是不是梯度爆炸,其次检查mask是不是写错了。
5.2 超参数选择:经验值与排查方法
参数不要盲目抄开箱代码,要根据语料规模来定。下面给出我实际调参时比较稳的基线组合:
| 参数名 | 推荐范围 | 说明 |
|---|---|---|
| embedding_dim | 100~200 | 数据量大可适当加大 |
| hidden_dim | 100~256 | 语料小用128,大数据用256 |
| num_layers | 1~2 | 数据不大时单层通常更稳 |
| dropout | 0.3~0.5 | 数据少往上调 |
| lr | 1e-3(Adam) | 若用SGD可降到1e-2配合动量 |
| batch_size | 32~64 | 句子越长越要小batch |
| max_len | 64~128 | 超长文本单独截断,不硬塞 |
我见过很多人调参时习惯于把词向量维度直接设成300,觉得越大越好。但embedding层如果维度太高,BiLSTM的输入维度也会跟着升高,参数量是乘数关系急剧膨胀,在小数据集上反而容易过拟合。解决思路是优先调整dropout和num_layers,这两种手段对F1的影响通常比embedding_dim更直接。
5.3 评测指标:词级P/R/F1怎么算
分词效果不能靠loss判断,必须看词级别的精确率、召回率和F1。具体计算方式是把预测出的词序列和标准词序列在词边界上对齐,分别统计正确预测的词数。
精确率是“预测出的词中有多少是对的”,召回率是“标准词中有多少被找出来了”,F1是二者调和平均。评价的时候还需要额外关注未登录词召回率,这个指标反映模型真正识别生词的能力。经验上,BiLSTM+CRF在通用新闻语料上F1可以到96%以上,但如果在垂直领域做评测,比如医疗病历,F1降到80%也很正常,因为术语形态和通用领域差异大。看到F1下降不用慌,先检查是不是语料标注体系不一致,再检查有没有足够的领域数据进行微调。
有一种常见情况是你训练过程中loss一直降,F1却纹丝不动。这时要优先怀疑两种可能:数据标签体系混乱,比如部分语料用BIO、部分用BIE;二是预测结果后处理太粗糙,比如直接把padding位置也当成真实词输出。把预测标签序列打出来看一眼,比盯着曲线猜快得多。
6. 常见问题、避坑技巧与后续扩展
6.1 训练阶段最容易出问题的三个位置
第一个是padding mask没有对齐。CRF环境里mask是用来保护真实计算范围的,任何时候都不能把padding位置带入发射得分和转移得分。第二个是学习率设置过高。我之前遇到过loss前几个batch降得飞快,后面直接nan,把lr从1e-3调整到5e-4再配合梯度裁剪,问题立刻缓和。第三个是标签索引与转移矩阵不对齐。如果训练时用B=0、I=1、E=2、S=3,预测时又用B=1开头的索引,最后结果会错得非常离谱,这类bug往往比模型问题更隐蔽。
6.2 让模型更适应真实场景的几个技巧
真实场景中的句子往往没有规范标点,还可能包含数字、英文、表情符号。BiLSTM+CRF按字处理,天然可以处理这些,但有几个优化技巧值得记录。训练数据里可以保留所有标点并把标点强制标注为S,让词边界切分更稳定;数字和英文单词尽量预处理成统一占位符,比如把所有连续数字替换成[NUM],这样模型遇到不同数字也能复用相同特征。训练一批真实场景数据往往比单纯堆叠高质量标注数据带来的提升更明显。
另外,如果觉得纯BiLSTM+CRF在长句上表现不够,可以加一层self-attention或者干脆换成BERT。近两年工业界用得最多的是预训练模型加CRF的搭配:预训练模型负责编码,CRF保证结构化输出。但如果只是做一个轻量级分词服务,BiLSTM+CRF的计算量只有BERT的几十分之一,部署成本低得多,在CPU上也能跑到不错的时延。能接受GPU开销且对准确率要求极高,再考虑BERT方案。
6.3 这条技术路线的后续演进:从BiLSTM+CRF到预训练时代
如果你准备继续往下深挖,我建议从两个方向扩展。一是把模型从词法分析任务扩展到命名实体识别(NER)、中文词性标注等同类序列标注问题,核心代码几乎不用改,只需换掉标签体系和任务数据。二是研究如何把词典特征注入到模型里。BiLSTM+CRF的纯粹字符输入有时会被词典约束所限制,如果在输入层把“当前字能否组成某个已知词”的信息拼进去,未登录词问题会改善很多。这些扩展本质上都是在调整序列标注模型的输入编码和标签建模,架构层面你已经具备了完整基础。
另外一个值得做的实验是词频统计和CRF转移矩阵的对比。训练完成后把model.transitions.data打印出来,你会看到B到I和B到E的得分显著高于B到S,这正好对应了语言学上的词法规则,而模型从数据里无声无息地学了进去。这种训练出来的知识可解释性正是CRF层至今没有被扔掉的原因。
最后再分享一个我从实践里得出的体会:训练分词模型最容易忽略的不是模型本身,而是语料标注口径的一致性。同一个词在这一条句子里拆开,在另一条里合起来,模型学起来会非常分裂。先花一天时间把数据规范统一好,比换十种模型结构都管用。模型的代码部分你已经有了,剩下的就是把数据一遍一遍地干净起来,效果自然就会稳定下来。