4、深度学习分词:从序列标注到预训练时代

好,咱们进入深度学习分词这个重头戏。说实话,传统方法做到极致也就那样了——CRF 再强,特征工程还是得靠人肉堆。我当年在电商搜索项目里,光商品标题的「iPhone 14 Pro Max 256G 暗紫色」这种组合,规则就写了上百条,最后还是漏了一堆长尾 case。后来换了深度学习,嗯,世界清净多了。

4.1 基于 BiLSTM+CRF 的分词模型

先聊聊最经典的组合:BiLSTM+CRF。说白了,就是把分词当成序列标注任务来做。每个字打一个标签:B(词首)、M(词中)、E(词尾)、S(单字成词)。

为什么用 BiLSTM?因为它能看上下文。你想想看,「南京市长江大桥」这句话,到底是「南京/市长/江大桥」还是「南京市/长江大桥」?只看一个字肯定不行。BiLSTM 从左到右、从右到左各扫一遍,每个位置都能拿到全局信息。

那为什么还要加 CRF?我踩过这个坑。纯 BiLSTM 输出的是每个位置独立的标签概率,它不管标签之间的转移约束。比如 B 后面不能直接跟 S,E 后面不能直接跟 M——这些规则 CRF 天然就能学出来。我在一个医疗文本项目里试过纯 BiLSTM,结果「心肌梗死」被分成了「心/肌梗/死」,加了 CRF 层之后,这种低级错误基本绝迹了。

核心要点:BiLSTM 负责提取上下文特征,CRF 负责约束标签转移的合法性。两者互补,缺一不可。
# 伪代码示意:BiLSTM+CRF 模型结构
class BiLSTM_CRF(nn.Module):
    def __init__(self, vocab_size, tag_size, embedding_dim, hidden_dim):
        self.embedding = nn.Embedding(vocab_size, embedding_dim)
        self.bilstm = nn.LSTM(embedding_dim, hidden_dim // 2, 
                              bidirectional=True, batch_first=True)
        self.fc = nn.Linear(hidden_dim, tag_size)
        self.crf = CRF(tag_size, batch_first=True)
    
    def forward(self, x):
        emb = self.embedding(x)           # [batch, seq_len, emb_dim]
        lstm_out, _ = self.bilstm(emb)    # [batch, seq_len, hidden_dim]
        emissions = self.fc(lstm_out)     # [batch, seq_len, tag_size]
        return emissions
    
    def loss(self, x, tags):
        emissions = self.forward(x)
        return -self.crf(emissions, tags)  # 负对数似然损失
我的经验:训练 BiLSTM+CRF 时,学习率别设太大,我一般从 0.001 开始,每 3 个 epoch 不下降就减半。另外,batch size 32 左右效果比较稳,太大反而容易收敛到局部最优。

4.2 基于 Transformer 的分词模型

BiLSTM 虽然好,但有个硬伤——它按顺序处理,并行度低。Transformer 就不一样了,自注意力机制一下子看到整个序列。2017 年 Google 那篇「Attention is All You Need」出来之后,NLP 界基本就变天了。

Transformer 做分词,核心是用 Encoder 部分。每个字通过自注意力计算与其他所有字的关系,然后输出每个位置的标签。相比 BiLSTM,它有两个明显优势:

  • 长距离依赖更强——「今天天气真不错,我们一起去公园散步吧」这种长句,Transformer 能直接捕捉「今天」和「散步」的关联,BiLSTM 得靠隐状态一步步传,容易衰减。
  • 训练速度快——并行计算,GPU 利用率高。我试过同样的数据量,Transformer 训练时间只有 BiLSTM 的 1/3 左右。

但注意,Transformer 也有坑。它没有位置信息的先天感知,全靠 Positional Encoding 硬塞。我曾经在古文分词任务上翻过车——「之乎者也」这种虚词位置敏感,Positional Encoding 如果没调好,模型会把「之」和「乎」的语义搞混。后来我换成了相对位置编码(如 Transformer-XL 那种),效果才上来。

避坑指南:我曾经在长文本(超过 512 字)上直接用 Transformer,结果 OOM 了。后来改用滑动窗口 + 重叠拼接的方式,每个窗口 256 字,重叠 64 字,既保住了上下文,又没爆显存。

4.3 预训练模型(BERT)在分词中的应用

终于聊到 BERT 了。说实话,BERT 出来之后,分词这件事的「天花板」被直接抬高了。为什么?因为 BERT 在海量语料上预训练过,它已经学会了「词」的通用表示。你拿它来做分词,相当于请了个语言学博士来帮你标注——它知道「深度学习」是一个整体,而不是「深/度/学习」。

具体怎么做?很简单:把 BERT 当作特征提取器,后面接一个 CRF 层。输入句子,BERT 输出每个字的向量表示,然后 CRF 做标签解码。这就是经典的 BERT+CRF 范式。

# 使用 HuggingFace Transformers 加载 BERT
from transformers import BertTokenizer, BertModel
import torch.nn as nn

class BertCRF(nn.Module):
    def __init__(self, tag_size, bert_path='bert-base-chinese'):
        super().__init__()
        self.bert = BertModel.from_pretrained(bert_path)
        self.fc = nn.Linear(self.bert.config.hidden_size, tag_size)
        self.crf = CRF(tag_size, batch_first=True)
    
    def forward(self, input_ids, attention_mask):
        outputs = self.bert(input_ids, attention_mask=attention_mask)
        sequence_output = outputs.last_hidden_state  # [batch, seq_len, 768]
        emissions = self.fc(sequence_output)         # [batch, seq_len, tag_size]
        return emissions

我个人的习惯是,先用 BERT 做微调,而不是从头训练。因为 BERT 已经很强了,你只需要在领域数据上跑几个 epoch 就行。我在金融公告分词项目里,用 BERT+CRF 只训练了 3 个 epoch,F1 值就从 BiLSTM+CRF 的 94.2% 涨到了 97.8%。

效果对比(我在一个 10 万条新闻语料上的实测):
模型 准确率 召回率 F1 值 训练时间
BiLSTM+CRF 93.5% 92.8% 93.1% 2.5 小时
Transformer+CRF 95.1% 94.6% 94.8% 1.2 小时
BERT+CRF 97.6% 97.3% 97.4% 3.8 小时

你看,BERT 虽然训练时间长了点,但效果提升是实打实的。不过要注意,BERT 模型大,推理速度慢。如果线上要求毫秒级响应,我建议用蒸馏版(如 TinyBERT 或 AlBERT),精度只降 0.5% 左右,速度能快 3-5 倍。

我的建议:如果数据量小(比如几千条),直接用 BERT 微调容易过拟合。这时候可以先用 BERT 提取特征,然后训练一个轻量的 CRF 分类器——相当于把 BERT 当特征工程用,效果也不错。

最后总结一下:深度学习分词这条路,从 BiLSTM+CRF 到 Transformer 再到 BERT,本质上是「特征提取能力」的不断升级。BiLSTM 是手动挡,Transformer 是自动挡,BERT 就是自动驾驶——但别忘了,再好的模型也离不开高质量的训练数据。我见过太多人迷信 BERT,结果标注数据一塌糊涂,模型照样翻车。嗯,数据清洗永远是第一位的,这个道理,什么时候都不过时。