简介面向自然语言处理初学者与文本分类任务开发者这份源码基于BERT预训练模型与text_cnn卷积网络实现文本分类能力适合情感分析、新闻分类等场景界面友好可直接开展实验亦可作为课程设计与项目原型参考。压缩包共25个文件核心为16个Python源码文件覆盖数据处理、分词、建模、优化、训练、推理等环节另有Markdown文档、依赖清单、许可证及ipynb示例整体323KB结构紧凑readme与依赖列表便于研究参考和二次开发。已有428人学习下载。读者可获得可运行的BERT分类实现包括建模、优化、分词与预训练相关脚本以及基于TF Hub的Notebook文本分类示例能够理解BERT微调与CNN分类结合的完整思路方便在此基础上扩展自己的文本分类实验。对于需要快速落地文本分类任务的团队也是一个轻量级参考实现。1. BERT不是文本分类终点接一层CNN在很多场景反而更稳做文本分类的团队现在默认流程已经变成“上来先把BERT跑通”但真正落到线上服务时你会发现BERT在短文本、观点句、领域小样本分类这类任务上要么显存吃紧要么过拟合翻车要么推理延迟压不下来。于是很多人又把目光放回CNN卷积神经网络——不是拿它替代BERT而是拿它兜住BERT的尾巴。所谓基于BERT的文本分类CNN模型设计源码核心就一件事让BERT负责把句子表示成向量序列让CNN在序列上抓局部关键模式再交给全连接层做分类。这个组合既保留BERT的语义理解能力又借CNN的平移不变性把小样本任务稳住是源码里常见且实操性很强的折中方案。下面从设计原理讲到可复现代码再给你一版踩坑清单。2. BERTCNN文本分类的设计逻辑为什么BERT输出的向量还要再过卷积2.1 BERT输出什么形状的张量CNN拿它当什么BERT不是文本分类的终点而是文本编码的起点。用transformers库加载模型后你把一句话喂进去得到的last_hidden_state形状是[batch_size, sequence_length, hidden_size]对bert-base来说hidden_size768。这层输出意味着句子中每一个token都被表示成一个768维的向量而且这个向量已经通过多层Transformer做过双向上下文融合。但关键点在于这768维向量是按“词”组织的不是按“特征图”组织的。想在它上面做分类常见做法有两种第一种是只取[CLS]向量拼一个全连接层就完事第二种是把整条token序列当成一张“伪图像”交给CNN在局部窗口内提取n-gram级别的组合模式。第一种做法简单但[CLS]向量是整句话的压缩表示它对“哪个片段真正触发了类别判断”这件事不敏感。比如你要判一条工单是不是投诉句子里“发货慢”出现三次这种关键词密度信息在[CLS]里容易被稀释。而CNN就不一样卷积核天然扫过连续窗口3-gram、5-gram这类局部特征会被显式捕捉。所以B站新结构里BERT的输出会先调整维度从[batch, seq_len, hidden]转成[batch, hidden, seq_len]让卷积核沿时间步方向滑动这一步是源码里第一个容易写错的地方。2.2 三种常见网络结构项目里选哪一种在源码设计里基于BERT的CNN分类模型一般有三种接法不是随便选的。第一种是“单层CNN接最后一层输出”也就是上面说的把last_hidden_state当成伪图像。结构是BertEncoder - Transpose - Conv1d - ReLU - GlobalMaxPooling - FC。这个结构简单显存占用小适合句子长度300以内的短文本也是本项目标题下最标准的实现。第二种是“多尺度CNN并行”你会看到源码里卷积核尺寸往往不只一个常见是[2,3,4]、[3,4,5]或[3,5,7]。每个卷积核负责捕捉不同粒度的局部特征核宽3看到的是三元词组核宽5看到的是五元词组。多个通道的特征拼在一起再进全连接层这种方式对小样本分类提升明显因为它相当于把“词袋”升级成“词组袋”。第三种是“BERT层输出拼上原始词向量”。意思是把BERT输出的last_hidden_state与word embedding拼接作为CNN输入。这种做法的出发点是BERT的向量在微调初期不稳定拼上固定词向量相当于给了CNN一条稳定通路。但实测下来训练速度会慢一截而且在小数据集上收益不稳定我一般只在样本量超过5万的时候才考虑它。2.3 这个设计的适用边界什么时候该用什么时候别硬用必须说清楚BERTCNN不是万能药它有自己的适用边界。适合的是短文本、类别数5以内的分类、训练样本五千到十万这个区间、推理时延要求不高但希望模型体积不至于太大的场景。最常见的落地场景是舆情分类、工单打标、电商评论正负向判断。不适合的场景也别硬套。如果句子平均长度超过512或者文本是长文档CNN的局部窗口根本没有意义这时候直接分段做向量平均或者用序列模型更靠谱。如果类别数超过20CNN的判别面不够还是要回到BERT全连接微调。如果你的业务方要求CPU上推理延迟低于5毫秒CNN接在BERT后面反而拖慢整体速度——因为瓶颈在Transformer前向不在CNN层。选型时还要关注一个指标样本量。BERT自己就是个黑匣子参数太多小样本上微调容易过拟合。CNN恰恰是个正则化器它限制模型只能从局部窗口组合特征变相压缩了假设空间。所以对10002000条样本的中小数据集BERTCNN经常比纯BERT微调高出三四个点这个结论你可以在自己的数据上复现。3. 用PyTorch实现BERTCNN文本分类最小可运行结构3.1 预处理与数据加载源码里最容易被忽略但不是最酷的部分是数据预处理。BERTCNN对输入格式有硬性要求必须有input_ids、attention_mask和token_type_ids三个张量。其中attention_mask的长度必须和input_ids严格一致token_type_ids在单句任务里全置0即可。我一般会单独写一个预处理函数避免在训练循环里反复调tokenizer。下面的代码是一个可直接落地的版本输入句子列表输出模型需要的三件套。import torch from transformers import BertTokenizer tokenizer BertTokenizer.from_pretrained(bert-base-chinese) def encode_texts(texts, tokenizer, max_len64): encoded tokenizer( texts, paddingTrue, truncationTrue, max_lengthmax_len, return_tensorspt, return_token_type_idsTrue, return_attention_maskTrue, ) return encoded[input_ids], encoded[attention_mask], encoded[token_type_ids] # 示例两条中文评论 texts [发货速度快包装严实, 质量不行客服态度差] input_ids, attention_mask, token_type_ids encode_texts(texts, tokenizer, max_len64) print(input_ids.shape) # torch.Size([2, 64]) print(attention_mask.shape) # torch.Size([2, 64])这里有两个容易被忽略的参数truncationTrue会硬截断超过max_len的部分直接丢弃return_token_type_idsTrue保证输出里有第三张量。如果你在源码里看到只返回了前两个tensor然后模型报错说缺少token_type_ids多半是这里漏了。max_len的设置会直接影响CNN的感受野设得太大padding占比高卷积核扫到大量无效位置设得太小长句子信息被截断。对中文短文本分类64到128是安全区间。训练集里句子普遍60字以内时max_len64就够如果包含200字的投诉工单直接拉到128。3.2 模型定义BERT编码器加多尺寸CNN核心模块定义在这里。整个模型分三段BERT取特征CNN提取局部模式全连接输出分类结果。BCELoss还是CrossEntropy取决于你的标签格式。import torch import torch.nn as nn from transformers import BertModel class BertTextCNN(nn.Module): def __init__(self, bert_model_namebert-base-chinese, hidden_size768, num_filters64, filter_sizes(2, 3, 4, 5), dropout0.3, num_classes2): super().__init__() # 1) BERT encoder默认冻结参数 self.bert BertModel.from_pretrained(bert_model_name) # 2) 多尺寸CNN卷积层 self.convs nn.ModuleList([ nn.Conv1d(hidden_size, num_filters, kernel_sizesize) for size in filter_sizes ]) # 3) 分类头 self.dropout nn.Dropout(dropout) self.fc nn.Linear(len(filter_sizes) * num_filters, num_classes) def forward(self, input_ids, attention_mask, token_type_idsNone): # shape: [batch, seq_len, hidden] bert_output self.bert( input_idsinput_ids, attention_maskattention_mask, token_type_idstoken_type_ids ).last_hidden_state # CNN要求通道维度在第二维转成 [batch, hidden, seq_len] x bert_output.transpose(1, 2) # 每个卷积核输出一个feature map做masked max pooling pooled_outputs [] for conv in self.convs: conv_out conv(x) # [batch, num_filters, seq_len - kernel_size 1] conv_out torch.relu(conv_out) # 关键点把padding位置的卷积结果置为极小值防止max pooling取到无效位置 mask attention_mask.unsqueeze(1) # [batch, 1, seq_len] mask mask[:, :, : conv_out.size(2)] conv_out conv_out.masked_fill(mask 0, -1e9) pooled conv_out.max(dim2).values # [batch, num_filters] pooled_outputs.append(pooled) # 拼接所有桶的特征 cat_features torch.cat(pooled_outputs, dim1) # [batch, num_filters * len(filter_sizes)] # 分类 logits self.fc(self.dropout(cat_features)) return logits这个类的设计有四个细节值得说。一是nn.ModuleList的写法它保证每个卷积核都被注册成模型的子模块参数会进优化器不能用Python列表替代否则训练时卷积核参数不更新这是源码里非常隐蔽的翻车点。二是seq_len经过卷积后会缩短所以对attention_mask做了切片对齐避免mask长度不匹配报错。三是masked_fill把padding位置的卷积结果替换成-1e9这样max(dim2)不会选中无效位置。四是self.bert默认是未冻结的但如果你在小数据集上做实验可以选择把bert.requires_grad_(False)只训练CNN和分类头效果往往出乎意料地好。3.3 训练循环与验证一套能跑通的代码骨架模型定义好之后训练循环本身没什么玄学但有一个点要特别注意BERT的AdamW优化器跟普通Adam不一样它解耦了权重衰减同时需要warmup来稳定前几百步的梯度波动。下面给的是一个可以直接跑的骨架注意看优化器和损失函数的匹配关系。import torch from transformers import AdamW, get_linear_schedule_with_warmup model BertTextCNN(num_classes2) device torch.device(cuda if torch.cuda.is_available() else cpu) model.to(device) optimizer AdamW(model.parameters(), lr2e-5, weight_decay0.01) total_steps len(train_loader) * num_epochs scheduler get_linear_schedule_with_warmup( optimizer, num_warmup_stepsint(0.1 * total_steps), num_training_stepstotal_steps ) criterion nn.CrossEntropyLoss() for epoch in range(num_epochs): model.train() total_loss 0.0 for batch in train_loader: input_ids, attention_mask, token_type_ids, labels batch input_ids input_ids.to(device) attention_mask attention_mask.to(device) token_type_ids token_type_ids.to(device) labels labels.to(device) logits model(input_ids, attention_mask, token_type_ids) loss criterion(logits, labels) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() scheduler.step() total_loss loss.item() # 每轮结束后做验证注意切到eval模式 model.eval() correct 0 total 0 with torch.no_grad(): for batch in val_loader: input_ids, attention_mask, token_type_ids, labels batch input_ids input_ids.to(device) attention_mask attention_mask.to(device) token_type_ids token_type_ids.to(device) labels labels.to(device) logits model(input_ids, attention_mask, token_type_ids) preds torch.argmax(logits, dim1) correct (preds labels).sum().item() total labels.size(0) acc correct / total print(fEpoch {epoch1} | Loss: {total_loss:.4f} | Val Acc: {acc:.4f})clip_grad_norm_这行值得重点说。BERT参数规模大训练后期偶尔会出现某个batch的梯度爆炸导致loss变成NaN。把梯度模长截断到1.0相当于给优化过程上了一份保险源码里如果没有这行你复现时大概率会撞上loss突然飞掉的情况。weight_decay只在非bias和Norm层上生效的写法我这里没展开AdamW内部已经处理了直接设0.01是经验值不用来回调。dropout在模型里出现的位置也有名堂。我建议只在全连接层之前加一次self.dropout不要在BERT输出后重复加多层dropout。BERT内部已经自带dropout了叠加太多会让模型在小样本上学不到东西。4. 关键参数怎么设从一轮跑通到效果能打4.1 参数速查表与默认值把源码里的参数打散看真正影响效果的变量不超过十个。下面这张表是实操验证过的起点值不是从论文里抄的可以直接当成第一版实验配置。参数推荐值影响方向max_len64或128短文本64长文本128再长会稀释CNN感受野filter_sizes(2,3,4,5)小卷积核适合短文本大核适合行为序列num_filters64或128越大特征越丰富小数据集建议64dropout0.30.50.3起步过拟合明显再往上调learning_rate2e-5BERT微调的通用区间超过5e-5容易崩weight_decay0.01AdamW默认推荐值batch_size16或32受显存限制BERT-base常用16epochs35BERT微调轮数不宜多多了过拟合grad_clip1.0防止梯度爆炸导致loss变成NaNnum_classes取决于标签数类别多时建议增加num_filters这里要说一个反直觉的经验BERTCNN模型在中等数据集上训练轮数很少超过5。原因在于BERT本身已经在大语料上收敛过我们只需要微调CNN层学到的是分类相关的局部模式学得太久反而把BERT原有泛化能力覆盖掉。如果你看到loss持续下降但验证集精度止步不前尽早早停这不是模型没学好是在过拟合。4.2 卷积核尺寸与通道数不同任务怎么调filter_sizes的选择和文本粒度强相关。中文短文本分类任务字级别的输入下2-gram对应的就是双字词3-gram对应三字短语。实测下来情感分类用(2,3,4)效果不错这符合情感词“开心/不满意/太差了”多为双字到四字的直觉而主题分类或意图识别因为类别间差异体现在句式层面卷积核拉到(3,4,5)甚至(4,5,6)更合理。num_filters决定了每个卷积核提取多少种特征。64起步128是上限。在小样本任务上盲目升到256反而容易过拟合因为每个卷积核都有独立的768×k_size×num_filters个参数这些参数都需要足够样本来稳定更新。你可以在源码里做一个简单实验把num_filters从64调到128如果验证集精度没涨那说明数据量不够撑起更多参数调回去。还有一个容易被忽略的尺寸参数kernel_size相对max_len的比例。当max_len64时核宽5覆盖了约8%的序列长度这是合理的但如果max_len512核宽5的感受野占比只剩1%几乎看不到长距离依赖。所以长文本场景下要么缩短max_len要么增大核宽到7或9两者必须配套调整。4.3 冻结策略与学习率BERT微调中的两难选择BERT部分参数动还是不动这是源码里最值得纠结的设计决策。常见做法之一是全部解冻让BERT和CNN联合训练效果上限高但需要足够数据。常见做法之二是冻结BERT全部参数只把last_hidden_state当作固定的特征提取器训练CNN和分类头优点是小样本也不容易过拟合缺点是特征不是面向任务优化的上限低。我推荐一个折中策略冻结BERT的前9层只微调最后3层和CNN层。理由是BERT的前几层学习的是通用语法特征后几层才与任务相关。实现上只需要在定义模型后加几行代码for name, param in model.bert.named_parameters(): if name.startswith(encoder.layer.9.) or name.startswith(encoder.layer.10.) or name.startswith(encoder.layer.11.): param.requires_grad True else: param.requires_grad False这个写法是给BERT的12层编号从0开始所以layer.9到layer.11是最后三层。需要注意bert.embeddings的position embedding也得跟着冻因为位置信息是通用特征。另外pooler层默认也是冻结的反正CNN不用它。学习率同样要分阶梯。BERT部分的lr保持2e-5CNN和分类头部分用1e-3这需要给模型参数分组optimizer_grouped_parameters [ {params: [p for n, p in model.named_parameters() if bert in n], lr: 2e-5}, {params: [p for n, p in model.named_parameters() if bert not in n], lr: 1e-3}, ] optimizer AdamW(optimizer_grouped_parameters, weight_decay0.01)这个分组背后的逻辑是BERT参数已经预训练过学习率太大一步就把内部表征洗掉CNN是从零开始训练需要相对快的学习率才能在有限轮次内收敛。如果你发现两个部分用同一个学习率效果很差多半就是这个问题。5. BERTCNN的五个常见坑现象、原因、解决5.1 坑一验证集准确率高线上预测全乱套现象训练和验证阶段accuracy都能到90%以上但模型部署到测试环境后随便来几条新样本预测结果明显不对。原因attention_mask没参与卷积池化运算。当句子长度小于max_len时padding位置对应的卷积输出参与max运算后如果padding位的向量恰好大于真实文本位的向量池化出来的特征就是padding带来的噪声。训练集里如果每个batch的padding比例不同模型就被带偏了。解决在池化前做masked_fill。具体代码就是第3.2节里的做法把attention_mask对齐到卷积输出的长度对mask为0的位置填充一个极小值-1e9再做max维度归约。如果这步已经做了检查一下attention_mask是不是被重复使用了——比如同一个batch先用在BERT输入上然后在CNN阶段直接用了原始mask但没有考虑卷积后序列长度变短了对齐问题。5.2 坑二loss降不下去甚至原地打转现象训练了500步loss始终维持在2.4左右不动精度也在随机水平附近徘徊。原因最常见的是tokenizer和模型不匹配。比如用bert-base-uncased的tokenizer配了bert-base-chinese的模型词表id对应不上输入的全是乱码向量。另一个常见原因是优化器分组错误BERT部分的学习率设成了1e-3直接冲坏了预训练权重。解决第一件事检查tokenizer.vocab_size和model.config.vocab_size是否一致。不一致就换tokenizer别硬跑。第二件事检查优化器参数分组确认BERT层用的是2e-5档位。第三件事把batch_size降到8排除显存溢出导致的静默报错。5.3 坑三显存溢出batch size只能调到1现象batch size设为16模型刚加载完就报OOM被迫只能跑batch size2甚至1训练慢到无法接受。原因BERT-base每层有110M参数一条64词的中文句子会产生[1,64,768]的中间激活加上CNN层的特征图显存比纯BERT微调高约20%。如果同时开了梯度检查点情况更糟。解决有两条路。第一条是缩短max_len从128降到64显存占用直接降一半付出的代价是长文本尾部的信息被截断但对于短文本分类影响很小。第二条是把CNN从Conv1d换成可分离卷积或把num_filters从128降到64。另外别忘了开混合精度训练from accelerate import Accelerator accelerator Accelerator(mixed_precisionfp16) model, optimizer, train_loader accelerator.prepare(model, optimizer, train_loader)fp16模式下显存占用约为fp32的一半训练速度还能提升30%左右。这也是现在给BERT做部署前训练的标准做法。5.4 坑四小样本数据集上反复过拟合验证集波动剧烈现象训练集只有1500条训练到第3个epoch时验证集精度先升后降不同随机种子跑出来结果差10个百分点。原因BERT参数太多CNN的卷积核也引入了大量可学习参数整体模型容量远超数据量所能约束的范围。加上小样本下batch内噪声大验证集本身也小一个batch的好坏直接影响验证集分数。解决先把这些限制加上。冻结BERT前几层只留最后2层微调dropout提到0.5num_filters降到32然后加一个简单的early stopping连续3个epoch验证集acc没提升就停。还有个偏方是给输入做文本增强把句子里的同义词随机替换构建一个augmented训练集。这个方向在你源码里可能没体现但加一条增强分支通常能稳定住23个点。5.5 坑五训练和推理阶段结果不一致部署时掉点现象离线测试时F1是86%上线后同分布样本F1掉到81%且掉点稳定复现。原因模型定义里没有区分训练态和推理态。因为nn.Dropout只在model.train()时生效推理时自动关闭但我们常常在验证脚本里只调用了model.eval()但没切换BERT模块状态。更隐蔽的是如果你在源码里手动调用过model.bert.train()或model.bert.eval()这个操作会覆盖掉整个模型的状态导致推理时BERT部分仍处于训练模式引入随机dropout噪声。解决所有前向推理前只调用一次顶层model.eval()不要单独操作子模块。如果你有自定义的推理脚本在加载模型后打印model.training确认是False。还有一个规范性做法把推理部分封装成一个接口函数函数内固定model.eval()且with torch.no_grad()让推理态与训练态彻底隔离。6. 进阶验证技巧怎么确认CNN真的在干活而不是拖后腿6.1 用消融实验验证CNN的真实贡献当你把BERTCNN模型跑出结果后不要急着上线先做一次消融实验。做法很简单把CNN层替换成一个mean pooling操作直接对BERT的输出取平均后接全连接层其他训练参数完全不变跑一遍对比。如果两个版本精度差不多说明你的任务里局部n-gram特征不重要CNN纯属白加如果CNN版本高了2个点以上说明特征提取确实有效。这个对照实验的成本只需要改几行模型代码但它能帮你回答“CNN到底为何物”这个源码评审时必被问的问题。我踩过一次坑做一个垃圾评论识别时BERTCNN跑出的F1比纯BERT高3.5个点我以为是CNN的功劳后来一消融才发现原来是加了dropout和输出层参数量的差异带来的正则化效应跟CNN没多大关系。这类血泪经验在复现任何源码时都值得复制一遍。6.2 一个值得投入的改进方向BERT冻结加小卷积核组合如果你验证后确认CNN有效下一步最划算的改进是冻结BERT全部参数同时把卷积核尺寸调小到(2,3)只训练CNN和分类头。这个组合在你的max_len比较大时推理速度能比完全微调快1.5倍显存占用降一半精度只损失1个点以内。为什么冻结BERT反而在小样本上更好用因为BERT在预训练阶段学到的通用语义特征是稳定的微调整个模型时反向传播会同时调整低层语法特征和高层语义特征导致原本稳定的表示被扰动。冻结之后BERT变成一个固定特征提取器CNN只需要在这些特征上学习线性决策面符合小样本下的bias-variance权衡。动手时注意一个细节冻结BERT后CNN输入仍然是768维但梯度只流向CNN层所以loss下降速度会比之前慢这时把CNN的num_filters从64升到128相当于把复杂度从BERT转移到CNN上效果通常更好。验证模型是否真正学到位我会用badcase分析代替只盯acc把验证集里预测错误的样本捞出来按预测置信度排序看高置信度错误集中在哪些类别上。如果错误样本主要集中在某两个近义标签上比如“投诉”和“咨询”说明特征区分度不够优先调整卷积核尺寸而不是加数据。这算是我做文本分类以来最顺手的一条经验也是源码改成生产系统的最后一步。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?