简介:这是一份融合BERT-wwm、CNN与双向LSTM的中文文本分类与情感分析完整工程,面向具备Python和深度学习基础、希望提升中文情感分析效果的开发者。资源包含主程序main.py、预训练模型chinese_wwm_ext_pytorch、模型配置bert_config.json与词表vocab.txt,以及模型结构图,清晰展示BERT-wwm负责全局语义、CNN提取局部n-gram特征、Bi-LSTM捕获前后文信息的整体架构。压缩包内共8个文件,涵盖py源码、txt说明、xlsx训练数据、bin模型权重、png结构图和mp4视频教程,包体约395MB。视频教程逐步演示了数据读取、模型构建、训练与评估过程,配合淮安评论合集数据集,可快速复现中文情感分析实验。目前已有573人学习下载,适合用于课程设计、毕业设计或自然语言处理项目参考。
1. bert-wwm+cnn+双向lstm:为什么这个组合成了文本分类的中坚方案
把 bert-wwm+cnn+双向lstm 这个组合拆开看,它其实是中文文本分类和序列标注任务里一套非常常见的三段式骨架:先用预训练模型把句子变成带着上下文的向量,再用卷积抓局部 n-gram 特征,最后让双向 LSTM 把时序关系再梳理一遍。很多人一开始觉得这是在叠模块,图个心理安慰,但真正在几万条酒店评论、法律文书或者电商客服工单上跑过之后会发现,这套结构比单用 BERT 或单用 BiLSTM 都稳,尤其在小样本和长文本混合的数据上,收敛速度和最终 F1 都有可感知的提升。这篇笔记就把它的原理、PyTorch 实现、参数调节和容易翻车的地方一次说透,给准备拿它做文本分类或序列标注的人一条能直接落地的路径。
2. 拆开三个组件:bert-wwm 的整词掩码、CNN 的局部特征、双向 LSTM 的上下文建模
2.1 bert-wwm 到底和 BERT 有什么不一样
BERT-wwm 是哈工大讯飞联合发布的 Whole Word Masking 中文预训练模型。原始 BERT 在预训练时做的是子词级掩码,对中文来说就是随机盖住某个汉字,模型只能通过单字上下文去猜。而 bert-wwm 先把中文句子按词切开,掩码时整词一起盖住,让模型必须从更完整的语义线索里推断被盖住的词。这个差异对于中文任务很关键,因为中文里很多歧义要靠词边界才能消解。
实际使用时你不需要自己去分词,模型自带的 tokenizer 会完成字级别的切分。但要注意的是,预训练权重是在整词掩码策略下学出来的,所以推理时不要拿 jieba 分词结果硬拼 token 序列,直接用 transformers 里的 BertTokenizer 就好,否则 embedding 对齐会错位。
常见做法是加载hfl/chinese-bert-wwm-ext这个权重名,它是扩展版,词表更大,在很多任务上比基础版好一点点。加载方式是一个固定套路,后面第 3 章的代码里会一并给出。这个组件在整个模型里承担的是“语义黑匣子”的角色,它的输出是一串向量,长度等于输入 token 数,每个 token 对应一个 768 维的表示。
2.2 CNN 在文本任务里抓的是什么
CNN 在文本上的作用不是看图,而是通过一维卷积在序列维度上滑动,抓取连续的 n-gram 组合。比如卷积核宽度为 3,就等于每次看连续的三个 token,能捕捉“不太好”这类局部短语;宽度为 5 就能覆盖到“性价比挺高”这种更长的表达。多个不同宽度的卷积核并联,就是常说的 multi-scale CNN,比单一宽度更能兼顾短搭配和中长搭配。
在 bert-wwm+cnn+双向lstm 这个组合里,CNN 放在 BERT 后面,输入是 BERT 输出的 768 维向量序列。一个常见做法是用 Conv1d 把通道数从 768 映射到自定义的特征数,比如 128 或 256。这里有个容易被忽略的点:卷积核的宽度作用在序列维度上,而不是 embedding 维度上,所以kernel_size=3意味着相邻三个位置的 token 参与一次卷积计算,而不是把一个 token 的三个维度拉出来算。
实现上通常会堆两个不同宽度的卷积核分支,再把它们的输出沿着特征维度拼接起来。这种并行多宽度结构比串行卷积更稳,梯度也更容易回传。很多开源项目里提到的“cnn全层链接”概念,指的就是把多个卷积层的输出都连接到后续网络,而不是只把最后一层喂给 LSTM,我们在第 4 章参数部分会展开说。
2.3 双向 LSTM 在这个位置解决什么问题
CNN 处理完局部特征之后,序列里还残留一些跨区域的依赖关系。比如一个句子里前半部分说“环境不错”,后半部分说“但是隔音很差”,情感极性要到句尾才能定论。双向 LSTM 就是干这个活的,它从两个方向读取整个 token 序列,把每个位置的上下文信息压缩成两个隐藏状态,再拼接或相加成一个输出。
有人会问,BERT 自己不就是双向的吗?为什么还要再接一层双向 LSTM。答案是 BERT 的双向来自 Transformer 的自注意力,它擅长捕捉长距离关联,但对局部连续短语的偏序关系不如卷积和循环网络敏感。CNN 抓局部,LSTM 抓时序,两者是互补的。尤其在文本长度超过 200 token 时,自注意力的计算开销会明显上升,而 BiLSTM 的线性扫描成本相对可控,所以在长文本场景里这种混合结构有实际收益。
具体到实现,BiLSTM 的输入是 CNN 的输出,形状是(batch, seq_len, hidden_cnn),经过双向 LSTM 后得到两个方向的隐藏序列,一般把最后一个时间步的输出拼接起来,或者把所有时间步做 max pooling 后拼接,再送进分类层。这个选择会直接影响分类效果,参数章节会对比。
3. 在 PyTorch 里搭出 bert-wwm+cnn+双向lstm:从数据到输出
3.1 数据准备与标签对齐
这个模型的输入不只是文本本身,还需要把标签对齐到 token 级别。做文本分类时,整个句子的标签是一个类别 id,对齐很简单;做序列标注时,每个 token 都要有标签,而 BERT tokenizer 会把一个词拆成多个子 token,所以标签要对拆出来的每个子 token 都复制一份,同时要用 token_type_ids 和 attention_mask 配合计算损失时屏蔽掉 padding 位置。
先看数据处理的核心代码:
import torch from transformers import BertTokenizer tokenizer = BertTokenizer.from_pretrained("hfl/chinese-bert-wwm-ext") def encode_text(text, max_len=128): # 这里返回的是 input_ids、attention_mask 和 token_type_ids # 注意 tokenizer 返回的 sequence 长度是实际 token 数,超过 max_len 会被截断 encoded = tokenizer.encode_plus( text, max_length=max_len, padding="max_length", truncation=True, return_tensors="pt", return_token_type_ids=True, ) return encoded["input_ids"], encoded["attention_mask"], encoded["token_type_ids"]这段代码核心是encode_plus,它一次性完成分词、截断和 padding。padding="max_length"会把所有样本统一成长度,方便后面组 batch;truncation=True保证超长文本不会报错。这里有个细节,如果你做的是序列标注,标签也要按照 tokenizer 拆出来的长度做扩展,不能直接用字级别标签。如果标签是按字标注的,可以先把句子里的每个字映射成 token id 序列,再把每个字对应的标签复制到它的子 token 上,最后补齐到 max_len。
3.2 模型结构:BERT-wwm 输出怎么送进 CNN 和 BiLSTM
模型的整体结构分四段:BERT-wwm 做向量化,CNN 做局部特征抽取,双向 LSTM 做时序建模,最后接一个全连接输出层。下面是完整定义,可以直接复制到 PyTorch 工程里:
import torch import torch.nn as nn from transformers import BertModel class BertWwmCnnBiLSTM(nn.Module): def __init__(self, num_classes, freeze_bert=True, cnn_out=128, lstm_hidden=128): super().__init__() # 加载 bert-wwm 权重,这里固定用扩展版 self.bert = BertModel.from_pretrained("hfl/chinese-bert-wwm-ext") if freeze_bert: # 冻结所有 bert 参数,只训练下游结构,适合小数据集 for param in self.bert.parameters(): param.requires_grad = False # 两个不同宽度的 CNN 分支,宽度 3 和 5 self.conv1 = nn.Conv1d(768, cnn_out, kernel_size=3, padding=1) self.conv2 = nn.Conv1d(768, cnn_out, kernel_size=5, padding=2) self.relu = nn.ReLU() self.dropout = nn.Dropout(0.3) # 把两个 CNN 分支的输出拼接后,hidden 维度是 2 * cnn_out self.lstm = nn.LSTM( input_size=cnn_out * 2, hidden_size=lstm_hidden, num_layers=1, bidirectional=True, batch_first=True, ) # 双向 LSTM 输出维度是 2 * lstm_hidden self.classifier = nn.Linear(lstm_hidden * 2, num_classes) def forward(self, input_ids, attention_mask, token_type_ids): # 拿到 BERT 的最后一层隐层输出,形状 (batch, seq_len, 768) bert_out = self.bert( input_ids=input_ids, attention_mask=attention_mask, token_type_ids=token_type_ids, ).last_hidden_state # Conv1d 期望输入是 (batch, channels, seq_len),所以要调换维度 bert_out_t = bert_out.permute(0, 2, 1) c1 = self.relu(self.conv1(bert_out_t)) c2 = self.relu(self.conv2(bert_out_t)) cnn_out = torch.cat([c1, c2], dim=1) # (batch, 2*cnn_out, seq_len) # 再调回 (batch, seq_len, feature) 给 LSTM cnn_out_t = cnn_out.permute(0, 2, 1) cnn_out_t = self.dropout(cnn_out_t) lstm_out, _ = self.lstm(cnn_out_t) # 对序列维度做 max pooling,再拼接双向结果 pooled = torch.max(lstm_out, dim=1).values logits = self.classifier(pooled) return logits这段代码需要注意三个点。第一,BERT 的输出要 permute 成卷积期望的形状,这是初学者最容易报错的地方,Conv1d的输入必须是(batch, channels, length),而 BERT 给的是(batch, length, 768),必须调换第 1 和第 2 维。第二,两个 CNN 分支的 padding 设置了kernel_size // 2,这样卷积后序列长度不变,省去后面长度对齐的麻烦。第三,BiLSTM 返回的是所有时间步的隐层状态,我用torch.max在序列维度做了全局 max pooling,这样无论句子长短都能得到一个固定维度的向量送进分类层。
如果做序列标注而不是文本分类,就不需要 max pooling,直接把 LSTM 每个时间步的输出送进分类层,或者再叠加一个 CRF 层。第 6 章会说这个改法。
3.3 损失函数与训练循环
文本分类任务用交叉熵,序列标注用带 ignore_index 的交叉熵。训练循环本身不复杂,但有几个地方直接影响能不能收敛:
from transformers import AdamW from transformers import get_linear_schedule_with_warmup def train_one_epoch(model, dataloader, optimizer, scheduler, device): model.train() total_loss = 0.0 criterion = nn.CrossEntropyLoss() for batch in dataloader: input_ids = batch["input_ids"].to(device) attention_mask = batch["attention_mask"].to(device) token_type_ids = batch["token_type_ids"].to(device) labels = batch["labels"].to(device) logits = model(input_ids, attention_mask, token_type_ids) loss = criterion(logits, labels) optimizer.zero_grad() loss.backward() # 梯度裁剪,防止 LSTM 梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=2.0) optimizer.step() scheduler.step() total_loss += loss.item() return total_loss / len(dataloader)这个循环里值得说明的是梯度裁剪那一行,很多人觉得可有可无,但双向 LSTM 的 BPTT 在长序列上很容易梯度爆炸,不裁剪的话训练 loss 会在某个 batch 突然跳到 NaN。max_norm=2.0是一个保守值,任务越难可以适当放大到 3.0 或 5.0,但要配合观察 loss 曲线来定。
学习率调度用的是带 warmup 的线性衰减。BERT 这类预训练模型对学习率很敏感,warmup 比例一般设置成总步数的 10%,可以让模型先从平稳的小步长开始,避免在 early stage 把预训练权重冲坏。
4. 参数怎么调:冻结层数、CNN 核宽、LSTM 隐藏层和 dropout 的取舍
4.1 冻结 BERT 层数与学习率
很多人把这个组合跑崩,问题都出在 BERT 这一层。bert-wwm 权重是在大规模语料上训练出来的,直接拿全量参数去 finetune 会带来两个风险:一是小数据集上容易过拟合,二是 CNN 和 BiLSTM 的梯度同时回传到 BERT 时,优化过程容易不稳定。常见做法是先冻结 BERT,把下游结构训到接近收敛,再解冻最后两层做微调,这个策略在几千条样本的小任务上特别管用。
学习率要分开设置。BERT 部分用 2e-5 到 3e-5,CNN 和 BiLSTM 部分可以用 1e-3 到 2e-3,因为它们的参数是从随机初始化开始学的,需要更大步长才能在一个合理的时间内收敛。实际操作上可以用两个 optimizer,分别持有不同的参数组。如果嫌麻烦,也可以只用一个 AdamW,给不同参数组配置不同的 lr 属性。
4.2 CNN 核宽度与特征图数量
核宽度决定了模型能看到的局部窗口大小。在中文文本里,“不好”和“很不错”都是两到三个字,所以kernel_size=3是最基础的配置。如果分类对象是长短语,比如“服务态度特别差”,5 个字的窗口更合适,所以我习惯并行用 3 和 5 两个分支。个别的场景会加一个kernel_size=7的分支,但 7 会造成参数量明显上涨,小数据集上容易过拟合,能用 3 和 5 解决的就不加第三个。
特征图数量,也就是cnn_out,推荐从 128 起步。少于 64 的话特征表达太弱,分类头学不到足够的模式;多于 256 的话,后面接的 LSTM 输入维度会变得很大,参数量和显存开销一起上去。用 3 和 5 双分支且每分支 128 时,拼接后 LSTM 的输入维度是 256,配合隐藏层 128 的双向 LSTM,整体参数量在合理范围内,单卡 12GB 显存能跑 batch size 32 的 128 长度序列。
4.3 BiLSTM 隐藏层维度与双向融合方式
隐藏层大小一般取 128 或 256,和 CNN 输出维度保持同一个量级即可。过小会丢失时序信息,比如 64 在处理 200 token 的长文本时明显力不从心;过大则容易和 CNN 抢梯度。双向 LSTM 有两种常见用法:把两个方向的最后时刻输出拼接,或对所有时刻做池化。经验是 max pooling 比 last hidden state 稳定,因为文本分类的判别依据往往出现在句子的多个位置,max pooling 保留的是显著特征,而 last hidden state 把权重都压到了句尾一个点上,对“但是”这类转折词出现在后半句的情况特别不友好。
还有一点是层数的选择。BiLSTM 一层就够,堆到两层以上在小数据集上几乎必然过拟合。因为前面的 CNN 已经把局部特征抽好了,LSTM 只是做全局时序整合,不是深层次特征提取器,多一层只会让梯度路径变长。
推荐参数参考表
组合参数参考
| 参数项 | 推荐值 | 说明 |
|---|---|---|
| 冻结策略 | 先全冻结,后解冻最后 2 层 | 小样本起步,预防灾难性遗忘 |
| BERT 学习率 | 2e-5 | 高于 5e-5 容易不收敛 |
| CNN 核宽 | 3, 5 并行 | 覆盖字级短搭配和词级中搭配 |
| CNN 特征图 | 每分支 128 | 256 以上收益递减 |
| LSTM 隐藏层 | 128 | 双向后拼接为 256 |
| BiLSTM 层数 | 1 | 多层过拟合风险高 |
| dropout | 0.3 | 0.5 在短文本上太激进 |
| 梯度裁剪 | max_norm=2.0 | 防止梯度爆炸 |
| batch size | 16~32 | 看显存与序列长度 |
5. 避坑:这个经典组合最容易翻车的 5 个地方
标签对齐错位,损失函数算到 padding 上
现象:训练 loss 一直不降,或者验证集 F1 忽高忽低,看起来训练很努力但指标就是上不去。
原因:BERT tokenizer 会把单个汉字保留为单 token,但英文、数字或特殊符号可能被拆分,导致 token 序列长度和原始标注长度不一致。更常见的是,按 max_len 做 padding 后,标签没用-100填充,交叉熵把 padding 位置也当成真实类别来算。
解决:在encode_plus之后手动核对截断后的 token 序列,然后把标签列表同步截断,并补到-100长度。PyTorch 的CrossEntropyLoss默认忽略ignore_index=-100。
BERT 被解冻后训练直接崩掉
现象:解冻 BERT 之后,前几个 step loss 正常,突然一步冲高,然后 loss 变成 NaN。
原因:预训练权重在微调时的学习率过高,或者 CNN/LSTM 的梯度在回传时被放大。这种情况在 batch size 小、序列长时更容易触发。
解决:把 BERT 参数组的学习率降到 1e-5,给 LSTM 输出的梯度加裁剪,并把 warmup 比例升到 20%。如果 still 崩,就退回全冻结状态,确认下游结构能独立收敛后再解冻。
CNN 的 padding 设置不对,序列长度对不上 LSTM
现象:报错RuntimeError: input.size(0) should be equal to batch size或维度错位。
原因:卷积核宽度大于 1 时,输出序列长度会缩短。比如 kernel_size=5 且 padding=0,128 长度的输入卷积后变成 124,再和另一个分支拼接时,两个分支的长度不一致,或者和 LSTM 的序列维度对不上。
解决:把 padding 显式设为kernel_size // 2,确保卷积后序列长度等于输入长度。多分支并行时,前向传播里打印一下每个分支的 shape,很多问题一眼就能看出来。
双向 LSTM 在 CPU 上预测比单 BERT 还慢
现象:模型训练完了,上线预测单条文本要几十毫秒,比预期慢了好几倍。
原因:双向 LSTM 要串行跑两个方向,梯度计算和反向传播的开销比纯 Transformer 高很多。很多人以为加完 LSTM 模型更强,但代价是推理吞吐明显下降。
解决:在预测阶段把双向 LSTM 换成单向 LSTM,或者直接用两个方向的线性池化替代 LSTM,效果接近但速度更快。如果准确率损失太大,说明任务确实需要时序依赖,就把 LSTM 换成 Transformer 编码层做时序融合。
文本长度差异过大,max pooling 失去意义
现象:数据集里既有十几字的短评论,也有上千字的新闻报道,验证集在长文本上 F1 明显低。
原因:max pooling 取的是序列所有位置的最大值,如果一部分样本被截断到 128,一部分只写了 30 字,短的样本天然缺少特征位置,池化结果容易被 padding 位置的微弱信号干扰。
解决:用 attention pooling 替代 max pooling,让模型自己学习对哪个时间步加权。实现不复杂,可以对 LSTM 输出加一个可学习的注意力向量,算出每个位置的权重,再按权重求和。
6. 从文本分类迁移到 NER:一个改造技巧与验证方法
这个骨架的价值不止分类。拿它做命名实体识别时,只需把最后对整句做 max pooling 的分类头拿掉,换成对每个 token 做分类的输出层,再在训练时统计每个实体类别的 token 数量来调整权重。更常用的是在 BiLSTM 后面接一个 CRF 层,显式地约束标签转移关系,让“B-PER 后面只能接 I-PER”这种规则成为硬约束,而不是让模型自己学。CRF 的训练要注意使用维特比解码配合负对数似然损失,推理时用维特比寻找全局最优路径。
验证阶段不要只看单项指标。先固定随机种子把模型跑三遍取平均,排除随机性带来的误差;再统计每类的精确率和召回率,BERT-wwm 做 NER 时往往在 O 类上把实体边界啃掉,反映出来就是 B 标签召回高、I 标签精确率低,这时候要检查 CNN 的 kernel_size=3 分支是不是把实体内部边界磨平了,可以尝试把卷积核宽度收窄只留 2 和 3。最后还要单独切一个 500 条左右的人工难例集,里面故意放省略主语的口语文本和带书名号的嵌套实体,这类样本最能反映这个混合结构的实际边界。
我自己的习惯是先从最小配置开始,单宽度 CNN 加单向 LSTM,用验证集确认下游结构能正常收敛,再一步步加回双向和双分支,避免一上来叠满然后出了问题不知道怎么定位。这个流程帮我省掉了大量定位 bug 的时间。希望帮到你。
本文还有配套的精品资源,点击获取