PyTorch + BERT 多标签文本分类实战:从模型结构到阈值调优

PyTorch + BERT 多标签文本分类实战:从模型结构到阈值调优 简介一份基于PyTorch和BERT的多标签文本分类Python源码适合作为高校NLP课程期末大作业或课设参考。项目围绕BERT编码与多标签sigmoid输出层展开完整覆盖数据预处理、训练集构建、模型训练、预测推理与依赖管理并包含BERT预训练模型加载、二元交叉熵损失计算及精确率/召回率/F1评估等关键环节帮助读者掌握多标签分类的PyTorch工程实现。压缩包共8个文件含5个Python脚本、1个JSON数据文件、1个TXT依赖清单及1个Markdown说明文档整体仅6KB结构清晰轻量。已有61人学习下载。代码无需修改即可运行配有readme说明与示例数据方便从数据加载到结果评估的完整链路理解适合希望直接复用或深入学习工程细节的读者。1. 多标签文本分类是什么——为什么不能直接套Softmax做内容安全审核、工单自动打标或新闻分类时经常会遇到同一段文本命中多个类别的情况一条售后工单既涉及“物流延迟”又涉及“申请退款”一篇新闻稿同时属于“科技”和“互联网”两个栏目。这种每个样本可以同时拥有多个正类标签的任务就是多标签文本分类也是这次要实现的标题场景。很多人在单标签任务里用熟了一套Softmax加交叉熵的流程到了多标签直接照搬结果预测概率加起来恒等于1模型被迫在多个正确答案之间做二选一指标永远上不去。PyTorch BERT 的组合在这里几乎是课程设计最主流的选型BERT负责把文本编码成带上下文的向量PyTorch负责把向量映射成独立的标签概率再配合合适的损失函数就能跑起来。这篇博文按这个顺序展开先讲清模型结构和损失函数为什么这么选再给可直接运行的Python代码示例最后把最容易翻车的阈值设定、标签不均衡和验证方法讲透。2. 用PyTorch加载BERT做多标签分类的模型结构与损失函数多标签分类和单标签分类的本质区别在于输出空间的约束方式。单标签任务要求所有类别的概率之和等于1多标签任务则要求每个标签独立地判断“有”或“无”。这个区别决定了模型的最后一层和损失函数都不能沿用老一套。2.1 从BERT的分类头理解多标签的向量映射常见的做法是取BERT输出序列中[CLS]位置的向量作为整句话的语义表示。该向量维度是768bert-base或1024bert-large包含的是整句话的聚合信息。为了输出多标签的预测分数我们在这个向量后面接一个全连接层把维度从hidden_size映射到num_labels。import torch.nn as nn from transformers import BertModel class BertMultiLabel(nn.Module): def __init__(self, pretrained_pathbert-base-chinese, num_labels8): super().__init__() self.bert BertModel.from_pretrained(pretrained_path) self.dropout nn.Dropout(0.3) self.classifier nn.Linear(768, num_labels) def forward(self, input_ids, attention_mask): outputs self.bert(input_ids, attention_maskattention_mask) pooled outputs.last_hidden_state[:, 0] # 取[CLS]向量 pooled self.dropout(pooled) logits self.classifier(pooled) # 每个标签一个独立分数 return logits代码说明BertModel.from_pretrained加载预训练权重attention_mask的作用是让BERT忽略padding位置的注意力outputs.last_hidden_state[:, 0]取出句首[CLS]向量全连接层的输出维度等于标签数每一个位置对应一个标签的打分没有经过任何归一化。注意这里返回的是logits不是概率。多标签任务里最后要不要加激活函数取决于后面接什么损失函数。如果直接使用下列的BCEWithLogitsLoss就不需要手动加sigmoid损失函数内部会自动完成计算。2.2 损失函数选BCEWithLogitsLoss而不是CrossEntropyLossCrossEntropyLoss要求每个样本只属于一个类别它对输出做Softmax归一化天然带有“类别互相竞争”的语义。多标签任务里一个样本可以有多个正类各标签之间是独立事件每个标签都应单独判断概率因此要把输出层从Softmax换成Sigmoid损失函数采用二分类交叉熵BCE的逐标签展开。BCEWithLogitsLoss是pytorch提供的融合了Sigmoid和BCE的损失函数。它内部使用了log-sum-exp技巧比先过Sigmoid再过BCELoss数值上更稳定推荐作为默认选择。若数据存在标签不均衡它还能通过pos_weight参数直接给正样本加权这一点在后面的排错章节会详细说明。from torch.nn import BCEWithLogitsLoss criterion BCEWithLogitsLoss() # 内部含sigmoid输入logits即可参数说明默认情况下每个标签的正负样本权重相同当某个标签的正样本极少时需要传入pos_weight张量维度与标签数一致。这个参数在多标签场景下的作用效果远比在单标签任务中抽层采样更直接。2.3 BERT、TextCNN与LLM三个路线的选择差异很多人在做意图识别或文本分类课程设计时会在BERT、TextCNN和LLM大模型之间犹豫。如果目标是“单条短文本、10个标签以内、数据量在几千到几万条”BERT性价比最高微调成本低分类头的可解释性强也容易展示训练过程。TextCNN参数量小、训练快但语义理解上限有限适合做消融对比实验。而用GPT这类LLM做意图识别优势是零样本泛化劣势是需要设计提示词、推理延迟高且成本不可控课程设计答辩时很难展示完整的训练过程。下表从课程设计关心的几个维度做对比模型参数量级训练成本语义上限课程设计展示性TextCNN百万级极低低结构简单便于画网络图BERT亿级中高可微调、可画loss曲线LLM十亿级以上高很高偏向提示工程训练展示弱这轮对比的结论是本标题场景选BERT是稳妥方案。PyTorch是加载和微调BERT的基础框架transformers库处理分词和预训练权重加载剩下的训练循环全部由PyTorch原生的优化器、损失函数和DataLoader完成整体技术栈清爽答辩时也不会被追问到无法回答的部署细节。3. 多标签文本分类的Python代码示例数据准备与训练循环模型定义清楚后直接进入能跑通的Python代码示例。环境依赖建议pip install torch transformers scikit-learn pandasPyTorch版本建议2.x安装时注意先按本机显卡选择对应的CUDA组合包没有GPU则CPU版也能跑通但训练会慢很多。以下代码按“数据读取、Dataset封装、训练循环、评估函数”四步组织。3.1 自定义Dataset并构建标签多热矩阵多标签数据集常见格式是每行一条文本后面跟着该文本命中的标签列表。假设CSV文件有两列text和labels其中labels使用逗号分隔如“物流延迟,申请退款”。我们需要先把所有标签收集为固定顺序的标签表再将每条文本的标签列表映射成01多热向量。import pandas as pd import torch from torch.utils.data import Dataset, DataLoader from transformers import BertTokenizer class MultiLabelDataset(Dataset): def __init__(self, df, tokenizer, label_list, max_len128): self.texts df[text].tolist() self.label_texts df[labels].tolist() self.tokenizer tokenizer self.label_list label_list self.max_len max_len def __len__(self): return len(self.texts) def __getitem__(self, idx): text str(self.texts[idx]) encoding self.tokenizer( text, truncationTrue, paddingmax_length, max_lengthself.max_len, return_tensorspt, ) label_vector torch.zeros(len(self.label_list)) for lab in str(self.label_texts[idx]).split(,): if lab in self.label_list: label_vector[self.label_list.index(lab)] 1.0 return { input_ids: encoding[input_ids].squeeze(0), attention_mask: encoding[attention_mask].squeeze(0), labels: label_vector, }代码说明paddingmax_length会一次性把短文本统一补齐到指定长度truncationTrue截断超长文本返回字典中input_ids和attention_mask送入BERTlabels作为损失函数的监督信号。注意return_tensorspt返回的是3D张量因此补了squeeze(0)去掉batch维度避免数据格式不匹配。标签表的构建放在数据预处理阶段all_labels [] for labs in df[labels]: all_labels.extend(str(labs).split(,)) label_list sorted(set(all_labels)) label_to_id {lab: i for i, lab in enumerate(label_list)}解释set(all_labels)去重后转成排序列表保证每次运行标签顺序一致。label_to_id的作用是把“物流延迟”这种可读标签映射为数字索引训练时直接按这个索引把对应位置置1。3.2 训练循环与关键超参数设置训练代码的核心是标准的三段式前向传播得到logits与labels计算BCE损失然后反向传播更新参数。BERT微调需要重点关注学习率直接用默认的0.001几乎必然发散常见做法是设成2e-5到5e-5区间。from torch.utils.data import DataLoader from transformers import AdamW, get_linear_schedule_with_warmup def train_one_epoch(model, dataloader, optimizer, criterion, device): model.train() total_loss 0 for batch in dataloader: input_ids batch[input_ids].to(device) attention_mask batch[attention_mask].to(device) labels batch[labels].to(device) logits model(input_ids, attention_mask) loss criterion(logits, labels) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() total_loss loss.item() return total_loss / len(dataloader)关键点说明clip_grad_norm_限制了梯度最大范数防止个别批次中的极端样本把BERT参数冲偏optimizer.zero_grad()必须在每次更新前执行否则梯度会跨batch叠加。注意criterion接收的是logits和float类型的标签张量BCEWithLogitsLoss要求标签范围在0到1之间这点与CrossEntropyLoss接收整数索引完全不同。推理阶段的预测代码也一并给出方便课程设计里画出最终效果def predict_proba(model, dataloader, device): model.eval() all_prob [] all_true [] with torch.no_grad(): for batch in dataloader: input_ids batch[input_ids].to(device) attention_mask batch[attention_mask].to(device) logits model(input_ids, attention_mask) all_prob.append(torch.sigmoid(logits).cpu()) all_true.append(batch[labels]) return torch.cat(all_prob), torch.cat(all_true)说明推理阶段必须使用torch.no_grad()关闭梯度跟踪减少显存占用torch.sigmoid把logits映射到0到1区间每个值代表该类别的概率后续与阈值比较即可得到多标签预测结果。注意这里所有预测概率保存的是原始分数不建议在DataLoader里预先设好阈值因为阈值是需要单独调的超参数。推荐一组课程设计能直接用的训练参数参数推荐值设置理由batch_size8或16BERT对显存消耗大超过显存报错时优先减半max_len128文本分类场景通常足够太长增加计算量learning_rate3e-5BERT微调常用区间太大梯度爆炸太小收敛慢epochs5到8多标签任务一般比单标签多跑几轮才稳定warmup_ratio0.1前10%的步数线性预热稳定训练过程3.3 多标签评估别只用Accuracy多标签任务里Accuracy的定义有陷阱。如果预测的标签集合与真实集合完全一致才算对某样本有4个标签但只预测对3个会被判定为完全错误如果按“预测正确的标签数 / 总标签数”算则每隔样本权重大小不均。常见做法是统计每个标签的precision、recall、F1再做宏平均或微平均其中Macro-F1对少数类更敏感Micro-F1受高频标签影响更大。from sklearn.metrics import precision_recall_fscore_support def evaluate_model(all_prob, all_true, threshold0.5): pred (all_prob.numpy() threshold).astype(int) true all_true.numpy().astype(int) p, r, f1, _ precision_recall_fscore_support( true, pred, averagemacro, zero_division0 ) return {precision: p, recall: r, f1: f1}这段代码把阈值设为0.5做二值化计算Macro-F1。在课程设计报告里建议同时列出Micro-F1和每个标签单独F1能明显体现指标的严谨程度。下一章会专门说明阈值不固定为0.5的原因以及不均衡标签带来的影响。4. 多标签训练常踩的坑标签不均衡与padding干扰多标签任务里最常见、也是课程设计答辩时最容易被追问的问题有三个方向标签不均衡怎么处理、padding位置是否污染了模型、阈值能不能改。分别说清楚。4.1 用pos_weight处理标签不均衡多标签数据集的普遍规律是少量标签频繁出现大量标签难得出现一次。例如“正常”标签占了80%的样本“涉政违规”只出现1%。此时模型只要把后一个标签永远预测为0整体loss也能降得很低但实际效果完全不可用。BCEWithLogitsLoss的pos_weight参数就是为此设计的。其原理是提高正样本损失在总损失中的占比等效于人为增加正样本的采样权重。计算方式是负样本数除以正样本数每个标签一个权重import numpy as np def compute_pos_weight(all_true): all_true all_true.numpy() pos_counts all_true.sum(axis0) neg_counts len(all_true) - pos_counts pos_weight torch.tensor(neg_counts / np.maximum(pos_counts, 1), dtypetorch.float32) return pos_weight criterion BCEWithLogitsLoss(pos_weightcompute_pos_weight(train_labels))注意说明pos_counts为0时除以1防止除零这类标签不是没出现在训练集而是频次过低训练初期基本学不到特征需要后续结合数据增广或阈值调整来补救。pos_weight应该在训练前根据训练集标签矩阵计算一次而不是每个epoch都重新算。4.2 padding位置干扰模型的方式与对策BERT输入需要定长短文本会补一堆[PAD]标记。虽然attention_mask在注意力计算时已经屏蔽了padding但[CLS]向量仍然是从所有有效token聚合来的padding的影响已被控制到很低。真正容易出问题的是自定义loss时忘记乘mask。如果为了让部分困难样本获得更高权重而手写loss常见的错误写法是直接对整批logits与labels做BCE然后按对应位置的padding标签做掩膜。实际上正确做法是只在attention_mask1的位置计算损失但注意这里的掩膜针对的是标签向量而不是token序列因为标签是句子级别的。动手写自定义loss的常见方案是用BCE原始公式展开后自己加权但课程设计背景下去掉pos_weight改用逐标签权重表也是一种思路。经验是优先使用BCEWithLogitsLoss自带的pos_weight不要一开始就重写损失函数。4.3 损失函数不降或指标虚高的排查顺序多标签训练中最常碰到的现象是accuracy很高但F1很低原因是预测结果把少数类全部判负而accuracy统计时正样本占比极低模型盲目预测0也能拿高分。遇到这种问题按以下顺序排查。检查列表和排查顺序如下先看训练集每个标签的正样本比例若某标签低于5%则直接初始化pos_weight重新训练打印训练集的预测概率分布观察是否大部分样本概率集中在0.1以下若是则考虑降低阈值或增加训练轮数对比train loss和val losstrain loss持续下降但val loss掉不下去是过拟合信号应加大dropout并引入early stopping确认最后一层输出没有手动加过softmax因为BCEWithLogitsLoss自带sigmoid双重激活会让梯度信号提前饱和检查batch内标签矩阵的float类型nn.CrossEntropyLoss与BCEWithLogitsLoss的标签类型常被混用报错信息很容易误导排错方向这一节里涉及的最容易忽视的假设是多标签任务的默认阈值0.5并不总是合适尤其在不均衡场景下最优阈值往往向少数类概率的分布区间偏移下面一章给出具体验证方法。5. 用验证集搜索最优阈值并演示推理代码多标签分类的阈值不应该拍脑袋定为0.5。对正样本极少的标签模型输出0.3可能就已经是“很强”的信号把阈值定成0.5等于错过所有召回。常见做法是在验证集上对每个标签单独搜索最优阈值再统一用于测试集评估。import numpy as np def search_best_threshold(all_prob, all_true, label_list): best_thresholds [] all_prob all_prob.numpy() all_true all_true.numpy() for i in range(all_true.shape[1]): best_f1 0 best_t 0.5 for t in np.arange(0.2, 0.85, 0.05): pred (all_prob[:, i] t).astype(int) true all_true[:, i] tp ((pred 1) (true 1)).sum() fp ((pred 1) (true 0)).sum() fn ((pred 0) (true 1)).sum() f1 2 * tp / (2 * tp fp fn 1e-9) if f1 best_f1: best_f1 f1 best_t t print(f标签 {label_list[i]} 最优阈值: {best_t}, F1: {best_f1:.4f}) best_thresholds.append(best_t) return np.array(best_thresholds)这段代码对每个标签独立做阈值网格搜索衡量标准选择F1而非accuracy。优点是统计逻辑完全透明答辩时可以对着打印结果逐行解释。阈值随验证集变化属于正常现象不属于过拟合因为每个标签只引入一个标量参数对整体模型复杂度影响很小。测试阶段的推理脚本一般把最优阈值矩阵保存为文件预测时按标签维度分别二值化。下面的代码演示了如何加载已保存的模型权重并对单条新文本做预测def predict_single(text, model, tokenizer, label_list, thresholds, device): model.eval() encoding tokenizer(text, truncationTrue, max_length128, return_tensorspt) input_ids encoding[input_ids].to(device) attention_mask encoding[attention_mask].to(device) with torch.no_grad(): logits model(input_ids, attention_mask) prob torch.sigmoid(logits).squeeze(0).cpu().numpy() pred_labels [label_list[i] for i, p in enumerate(prob) if p thresholds[i]] return pred_labels, prob最终效果示例可能会看到同一句话同时打出“申请退款”和“物流延迟”两个标签这正是多标签分类与单标签分类效果差异最直观的呈现。课程设计答辩时把这段代码跑出来的样例截图放进报告配上前几章的loss曲线和多标签F1指标整个项目从模型到验证再到演示就形成了完整的逻辑闭环。本文还有配套的精品资源点击获取