
简介这是一份基于Bert实现中文情感分析与文本分类的完整工程面向计算机相关专业学生与开发者解决了从原始语料到训练、评估、可视化的一站式需求可作毕设、课设或入门进阶项目。压缩包共43个文件、42.14MB主要包含Python源码、JSON词表、CSV数据集、Markdown说明与Shell训练脚本等目录按data、processing、sentiment、topic、crawler、GUI、train、model划分其中sentiment与topic模块分别实现情感分析和主题建模processing提供清洗与特征提取工具GUI可将训练结果图形化展示便于替换数据与扩展算法。已有398人学习下载。除完整可运行的情感分类与文本分类代码外还提供训练好的模型权重、语料处理工具、词云与结果对比图脚本并附赠基于BERT的中文情感分类测试项目读者可用自带数据自行训练和调参结合GUI界面直观观察预测效果实践中掌握Bert微调、特征抽取与多任务建模的关键步骤。1. 基于Bert的情感分析与文本分类源码工程里到底该看什么情感分析和文本分类是NLP落地里最高频的两类需求从电商评论的正负向判断、客服工单的意图归类到舆情监控的情感倾向几乎每个业务方都会提一嘴。而基于Bert来做这件事已经是当前最不需要犹豫的选型——它不像训练GPT那样需要海量算力微调一个中文预训练模型就能在几小时内达到能用的准确率这也就是为什么大量 python 源码工程会以“Bert 情感分析 文本分类”作为打包内容。你拿到这样一份源码包如果只是为了跑通demo那它只是个教学玩具但如果你清楚每段代码在解决什么问题、改哪些参数能适配自己的数据它就是一个可以直接复用和扩展的工程起点。这篇就顺着“数据预处理 → 微调 → 评估 → 避坑 → 上线”的路径把这类工程拆开看。2. 数据和标签体系源码能复现的第一步先把训练语料收拾干净情感分析和文本分类看起来是两个任务但在Bert微调工程里它们的上游完全一致——都是把一段文本映射成一个离散的标签。源码包里通常放着类似train.csv、test.csv这样的文件列名无非是text和label但真正动手时你会发现数据清洗和标签映射的决策对最终F1的影响比模型结构大得多。2.1 三种常见数据格式与清洗边界我从实际工程里见到最多的输入格式是Excel、CSV和JSON三种。CSV最容易踩坑的是编码问题很多Excel导出的CSV是GBK编码而Python默认读取是用UTF-8。常见做法是打开文件时显式指定编码同时加上errorsignore兜底。Excel格式则要留意列名里有没有不可见字符或者多行表头。JSON格式相对安全但嵌套结构容易让人在解析时绕弯子。import pandas as pd # 常见做法先探测编码再读文件 def load_text_data(path: str) - pd.DataFrame: # 优先按 UTF-8 读失败再回退 GBK try: df pd.read_csv(path, encodingutf-8) except UnicodeDecodeError: df pd.read_csv(path, encodinggbk, errorsignore) # 只保留两列text 和 label df df[[text, label]].dropna() df[text] df[text].astype(str).str.strip() return df这段代码的关键在于errorsignore它不会帮你修复乱码但能让读取过程不中断。实战里更稳妥的方式是先把文件用文本编辑器打开看一眼确认表头和分隔符再决定读取参数。清洗边界要把握好度情感分析里标点符号和表情往往携带着情绪信息全删未必是好事。我一般只做三类清理——去掉HTML标签、去掉URL、把全角标点统一成半角具体要不要去停用词取决于你的领域数据。金融、医疗这些垂直领域停用词表反而可能误伤关键术语。2.2 标签映射与分层采样标签映射这件事新手最容易犯的错是把标签直接写成字符串丢给模型。Bert分类头输出的是概率分布监督信号必须是整数索引。所以源码里通常会维护一个label2id字典让文本标签和数字一一对应。这步看起来简单但在多分类场景里不同版本的训练集和预测集标签集合不一致是线上翻车的常见原因。# 建立稳定的标签映射表避免训练和预测时标签对不上 labels sorted(df[label].unique()) label2id {label: idx for idx, label in enumerate(labels)} id2label {idx: label for label, idx in label2id.items()} df[label_id] df[label].map(label2id) # 分层采样保证训练集和验证集里每个类别的比例一致 from sklearn.model_selection import train_test_split train_df, val_df train_test_split( df, test_size0.2, stratifydf[label_id], random_state42 )这里最值得强调的是stratify参数。如果不做分层二分类正负样本各50%的数据集还好一旦类别分布是9:1随机切分很可能让验证集里小样本类别只有寥寥几条导致验证F1波动剧烈给你“模型没练好”的错误信号。random_state42是伪随机种子固定下来是为了让每次跑实验的结果可对比实际复现时你可以换成任意整数但一定要固定。标签集合务必保存成文件比如label_map.json推理阶段直接加载避免模型上线后类别顺序不一致。2.3 截断长度的选择Berts序列长度的残酷上限Bert是Transformer架构自注意力机制的计算复杂度是输入长度的平方级所以原始模型把输入截断在512个token以内。中文场景下512个token大约对应三四百个汉字对大部分评论和短文本够用了但长文档分类就很吃亏。源码里通常会写死max_len128或max_len256这是在速度、显存和效果之间妥协的结果。from transformers import BertTokenizer tokenizer BertTokenizer.from_pretrained(bert-base-chinese) max_len 128 def encode_text(text: str): encoded tokenizer( text, max_lengthmax_len, paddingmax_length, truncationTrue, return_tensorspt, return_attention_maskTrue, ) return encoded[input_ids].squeeze(0), encoded[attention_mask].squeeze(0)paddingmax_length会把所有样本统一填充到128个token这样batch里每个样本长度一致才能组成张量送进GPU。truncationTrue是风险点——超出的部分会被直接截断如果一段关键情感词在文本后半段截断之后模型根本看不到预测自然出错。我做过一个电商平台的售后评价分类发现“退换货流程”这类词经常出现在第150个token之后把max_len从128提到256F1涨了两个点。所以拿到源码后不要急着跑先统计一下数据集里文本长度的分布确定合理的截断位置。3. Bert微调主流程从DataLoader到训练循环逐一拆解数据集和标签准备好了接下来是整个源码工程的核心——微调。这一段的目标是让你理解每一块代码在做什么、改哪个变量会影响训练效果。常见的做法是基于transformers库加载一个中文预训练权重然后在它前面加一个分类头用你准备的标注数据做监督学习。3.1 构建Dataset类与PyTorch DataLoader数据要被模型消费得先包装成PyTorch的Dataset接口。这个接口的核心是实现__len__和__getitem__两个方法。源码里最常见的写法是把DataFrame里的每一行文本做tokenize返回input_ids、attention_mask和label三个张量。import torch from torch.utils.data import Dataset class TextClassificationDataset(Dataset): def __init__(self, df, tokenizer, max_len): self.texts df[text].tolist() self.labels df[label_id].tolist() self.tokenizer tokenizer self.max_len max_len def __len__(self): return len(self.texts) def __getitem__(self, idx): text self.texts[idx] encoded self.tokenizer( text, max_lengthself.max_len, paddingmax_length, truncationTrue, return_tensorspt, ) return { input_ids: encoded[input_ids].squeeze(0), attention_mask: encoded[attention_mask].squeeze(0), label: torch.tensor(self.labels[idx], dtypetorch.long), }注意squeeze(0)这一步。return_tensorspt返回的张量是二维的形状是(1, seq_len)多出来的那个维度是batch维度在单个样本上不需要所以把它压掉。等DataLoader拿到多个样本后会自动在batch维度上堆叠出(batch_size, seq_len)的形状。还有一个细节label用torch.long因为CrossEntropyLoss要求标签是长整型用float会在某些PyTorch版本里报类型错误。DataLoader的配置也值得说。batch_size和num_workers是有讲究的前者受显存限制后者在Linux下可以开4到8个加快数据加载Windows下开0或1否则容易报多进程相关的错误。from torch.utils.data import DataLoader train_dataset TextClassificationDataset(train_df, tokenizer, max_len128) train_loader DataLoader( train_dataset, batch_size16, shuffleTrue, num_workers2, pin_memoryTrue )shuffleTrue对于训练集是必需的它让每个epoch里样本顺序被打乱避免模型学到样本顺序里的虚假规律。pin_memoryTrue是给GPU训练用的优化选项它让CPU内存锁定减少数据从CPU传到GPU的拷贝开销推理阶段可以关掉。3.2 加载预训练模型与训练循环到了最核心的部分。用BertForSequenceClassification加载预训练权重它是BertModel加一个线性分类头的组合。分类头的输入是[CLS]位置的向量Bert把这个位置的输出视为整个句子的语义聚合表示。from transformers import BertForSequenceClassification model BertForSequenceClassification.from_pretrained( bert-base-chinese, num_labelslen(label2id), id2labelid2label, label2idlabel2id, )num_labels是关键参数它决定分类头输出几个神经元二分类就是2。id2label和label2id传进去主要是为了方便模型直接输出文本标签Hugging Face的设计会在model.config里保存这两个映射关系预测时model(**inputs).logits加上这两张表就能直接解读业务含义。训练循环分五块优化器、学习率调度、前向传播、反向传播、评估。from transformers import AdamW, get_linear_schedule_with_warmup from torch.nn import CrossEntropyLoss epochs 3 optimizer AdamW(model.parameters(), lr2e-5) total_steps len(train_loader) * epochs scheduler get_linear_schedule_with_warmup( optimizer, num_warmup_stepsint(total_steps * 0.1), num_training_stepstotal_steps, ) loss_fn CrossEntropyLoss() device torch.device(cuda if torch.cuda.is_available() else cpu) model.to(device)lr2e-5几乎成了Bert微调的默认值原因在于预训练权重已经收敛到一个较好的语义空间步子迈太大会把它学到的知识冲掉这就是微调fine-tune和从零训练的本质区别。warmup_steps设为总步数的10%意思是前10%的步数里学习率从0线性爬升到设定值这是为了减少早期训练的不稳定。训练循环本体不复杂但有一点必须养成习惯——每轮训练后保存一次模型。for epoch in range(epochs): model.train() for batch in train_loader: optimizer.zero_grad() input_ids batch[input_ids].to(device) attention_mask batch[attention_mask].to(device) labels batch[label].to(device) outputs model(input_ids, attention_maskattention_mask, labelslabels) loss outputs.loss loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() scheduler.step() # 每轮训完存一次防止中途崩了白跑 torch.save(model.state_dict(), fbert_model_epoch_{epoch}.pt)clip_grad_norm_是很多人忽略但实际上能救命的操作。它把梯度的范数裁剪到1.0防止梯度爆炸导致的loss变成NaN。在Bert微调里这个操作被验证是稳定训练的默认配置。outputs.loss是BertForSequenceClassification内部算好的交叉熵损失所以前面定义的loss_fn在这里其实用不上——但保留它在代码里也不影响训练只是让你一眼看出这个任务用的是交叉熵。提示如果你在自己的数据上跑发现loss一直在0.6到0.7附近下不去先别调学习率检查是不是标签映射表有误或者数据里存在大量冲突标注——同一条文本出现两种不同标签。这是最隐蔽的数据质量问题。3.3 如何用验证集评估并选择最优模型光看训练loss不靠谱模型在训练集上过拟合是常态所以验证集的作用是告诉你模型是否学到了可泛化的规律。评估时要把模型切到eval()模式这会关闭Dropout和BatchNorm的训练行为让前向传播结果稳定可复现。from sklearn.metrics import f1_score, accuracy_score import numpy as np def evaluate(model, val_loader): model.eval() all_preds, all_labels [], [] with torch.no_grad(): for batch in val_loader: input_ids batch[input_ids].to(device) attention_mask batch[attention_mask].to(device) labels batch[label].to(device) outputs model(input_ids, attention_maskattention_mask) preds torch.argmax(outputs.logits, dim1).cpu().tolist() all_preds.extend(preds) all_labels.extend(labels.cpu().tolist()) acc accuracy_score(all_labels, all_preds) f1 f1_score(all_labels, all_preds, averagemacro) return acc, f1为什么用F1而不是只用Accuracy在一个正样本只占5%的情感分类任务里模型把所有样本都预测成负类准确率照样能到95%但没有任何业务价值。averagemacro对所有类别的F1取算术平均每个类别的权重相同避免大类别主导指标。跑完每个epoch的验证后对比F1选择最好的那次权重作为最终模型而不是用最后一个epoch的权重——这在训练后期过拟合加剧时是明显的差距。4. 从二分类到多分类文本分类任务的通用扩展路径和一个关键分界这个源码工程的标题同时写了“情感分析”和“文本分类”说明你拿到的可能不是单一任务而是一套可以互相切换的分类框架。情感分析通常是二分类或三分类正向、负向、中性而文本分类往往是更细粒度的多分类比如新闻领域分类、工单类型识别。两者的底层实现代码几乎一致差异集中在三个地方标签体系设计、损失函数处理和预测阈值校准。搞懂这三处这套源码就能同时服务两类任务。4.1 多分类的标签体系设计情感分析的三分类相对简单标签之间有天然的顺序感——负向、中性、正向。但文本分类不一样比如把工单分成“退款”“换货”“物流咨询”“售后维修”四类类别之间没有递进关系不应该用回归的思路去建模。好在BertForSequenceClassification天然支持任意数量的类别只要改num_labelslen(label2id)代码一行都不用动。需要补一个工程习惯多分类的标签映射表一定要写进配置文件并随模型一起保存。源码包里的model_config.json或label_map.json就是干这个的。模型训练后你只知道输出索引要还原成业务标签必须靠映射表。我见过不止一次线上预测脚本忘了加载映射表、直接拿索引当结果输出的翻车这个错误低级但杀伤力极大。4.2 类别不平衡时如何手动控制损失权重现实业务里的文本分类数据几乎不可能均匀分布多数类可能占七成少数类只有几个百分点。Bert微调用的是交叉熵损失它默认把所有样本一视同仁。在严重不平衡的数据上模型会倾向预测多数类因为这样做能让整体loss降到最低。缓解办法之一是给损失函数传入类别权重让少见类别的错判带来更大的惩罚。import torch.nn.functional as F # 用 sklearn 计算每个类别在训练集中的归一化权重 from sklearn.utils.class_weight import compute_class_weight class_weights compute_class_weight( class_weightbalanced, classesnp.array(sorted(label2id.values())), ytrain_df[label_id].values, ) class_weights torch.tensor(class_weights, dtypetorch.float32).to(device) # 在训练循环里手动计算加权交叉熵 outputs model(input_ids, attention_maskattention_mask) logits outputs.logits loss F.cross_entropy(logits, labels, weightclass_weights) loss.backward()compute_class_weight的计算逻辑是样本总数除以类别数再除以每个类别的样本数。样本多的类别权重小样本少的类别权重大。这种做法的代价是少数类可能被过分强调导致训练过程震荡所以需要配合早停或者更长的warmup来稳定。如果加了权重之后F1反而变差那就把class_weight参数调成None回到原始的均匀损失换个思路用阈值校准来解决问题。4.3 预测阶段的阈值校准调整分类决策边界才不是玄学模型输出的logits经过Softmax后得到每个类别的概率但默认的决策规则是“取概率最大的那个类别”。这在类别分布极度不平衡时是有问题的。比如一个负向情感识别任务训练集里负样本只有3%模型就算学得不错对模糊文本给出的负向概率也只会在0.2左右它永远竞争不过概率0.8的多数类。这种场景下正确做法是给少数类设置一个更低的判断阈值——概率超过0.3就算负向。probs torch.softmax(logits, dim-1) neg_prob probs[:, 1] # 假设负向是索引1 preds (neg_prob 0.3).long()阈值调到0.3还是0.35不是拍脑袋定的它来自验证集上的P/R曲线。常见做法是遍历0.2到0.5之间的若干个候选阈值找出F1最高的一组用于线上预测。这个步骤在源码里通常体现为一个单独的threshold_tuning.py脚本目的是把“分类决策边界”从模型训练中解耦出来让它变成纯调参过程。5. 避坑与常见问题跑这套Bert源码最常翻车的5类问题这一节是我最想写给读者的部分。源码能跑通和能在业务里稳定工作是两回事下面这几个坑是我自己在复现和上线Bert分类工程里真实踩过的每条都按“现象→原因→解决”的顺序写清楚。5.1 显存溢出batch_size降到2还是炸现象训练刚开始没几步GPU报CUDA out of memory。原因很多人以为batch_size是影响显存的唯一变量其实序列长度max_len和模型本身的规模同等重要。Transformer的显存占用随序列长度呈近似平方增长你把max_len从128改成256显存占用可能是原来的三四倍。另外一个隐蔽因素是PyTorch的CUDA缓存并不总是完全释放多次跑训练脚本后显存碎片化加剧小batch也会炸。解决把batch_size降到2、4这样的极小值先验证能跑通然后把max_len按真实数据分布重新确认。如果显存还是紧开启gradient_accumulation_steps累积多个小batch的梯度再更新一次参数效果等价于大batch显存占用却平摊了。# 梯度累积小显存跑大batch的工程解法 accumulation_steps 4 optimizer.zero_grad() for i, batch in enumerate(train_loader): outputs model(**batch) loss outputs.loss / accumulation_steps # 累积阶段缩小loss loss.backward() if (i 1) % accumulation_steps 0: optimizer.step() scheduler.step() optimizer.zero_grad()5.2 长文本被截断导致分类错乱现象验证集F1整体还行但某个包含大量上下文的业务场景里模型预测结果明显不符合常识。原因truncationTrue停在第512个token处超出部分直接丢弃。长文本里可能前半段是背景描述后半段才是真正的观点倾向模型学到的是不完整的语义。解决先统计训练集的文本长度分布如果存在大量长文本且关键信息不一定在前半段两个方向可以考虑。一是把文本做分段滑窗每段都过一遍模型再聚合概率二是直接用支持长序列的模型变体替换底座。具体到这套源码优先检查max_len的设置是不是拍脑袋填的128或256改成512通常能直观感受到长文本准确率回升代价是训练时长增加。5.3 标签严重不均衡但没注意现象训练loss一路下降训练集准确率极高验证集F1却不到0.3。原因模型把所有样本都预测成多数类了。这在情感分析任务里尤其常见——中性样本占大头正向和负向样本稀疏准确率虚高的背后是模型没有任何判断能力。解决训练代码里加入加权损失或者改用focal loss这类专门处理类别不平衡的损失函数。另外在评估指标上不要只盯accuracy以macro F1为准。这是为什么前面章节里我反复强调F1——它在类别不均衡场景下才反映真实能力。5.4 训练集和验证集分布不一致导致上线效果大打折扣现象训练和验证时F1都挺漂亮上线到真实流量后指标掉了一大截。原因源码包里给的train_test_split用的是随机切分这在离线实验里没毛病但如果你自己在准备业务数据时也用同一套随机切分你实际上把“时间因素”完全忽略了。真实场景里用户的语言习惯会随时间变化用8月份的数据训练9月份的数据验证你检测的就是泛化能力而不是历史的偶然相关性。很多源码工程为了方便演示直接随机划分这个习惯在正规实验里是有害的。解决按时间或者按ID做切分保证训练集不包含验证集时间之后的数据。如果源码是随机切分你自己动手改成test_size按时间戳排序后的尾巴部分。字段名可能是timestamp或id具体以你拿到的数据为准。5.5 采样随机种子不固定现象同一个源码工程两次运行出来的结果差异巨大过几天再跑又不一样。原因模型初始化的随机性、数据加载顺序的随机性、dropout的随机性相互叠加如果不固定随机种子实验之间完全不可比。解决在训练脚本开头固定所有能固定的种子。import random import numpy as np import torch def set_seed(seed42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) # 关闭cudnn的自动优化保证卷积/attention计算的确定性 torch.backends.cudnn.deterministic True这里torch.backends.cudnn.deterministic True会让算法选择变得确定代价是牺牲一点速度。训练阶段开它推理阶段可以关掉换取更低延迟。这句话就是一个保你实验结果可复现的“后悔药”——每次都固定出了效果波动才能判断是模型问题还是参数问题。6. 把模型真正用起来单条预测脚本与模型上线前的两道检查跑完训练拿到bert_model_epoch_2.pt权重只是一个中间产物你要交付的是能接收文本返回标签的服务。这里给出一份单条预测的完整代码也是我在实际项目里反复使用的模板。它解决的问题是如何把训练时的环境和推理时的环境解耦避免每次预测都重新走一遍训练代码。import torch from transformers import BertTokenizer, BertForSequenceClassification # 加载你训练时保存的权重和映射表 model_path ./bert_model_best.pt device torch.device(cuda if torch.cuda.is_available() else cpu) tokenizer BertTokenizer.from_pretrained(bert-base-chinese) model BertForSequenceClassification.from_pretrained(bert-base-chinese, num_labels3) model.load_state_dict(torch.load(model_path, map_locationdevice)) model.to(device) model.eval() def predict_single(text: str, threshold: float 0.3): encoded tokenizer( text, max_length128, paddingmax_length, truncationTrue, return_tensorspt, ).to(device) with torch.no_grad(): logits model(**encoded).logits probs torch.softmax(logits, dim-1).squeeze(0) confidence, pred_idx torch.max(probs, dim0) # 置信度低于阈值时宁可判为“待人工确认” if confidence.item() threshold: return -1, confidence.item() return pred_idx.item(), confidence.item()这里用了map_locationdevice是因为训练可能在GPU上完成推理环境未必有GPU这个参数让权重被加载到CPU或指定的设备上避免“加载时找不到CUDA”的报错。model.eval()是必加项它会关闭训练行为中的随机性保证同一条文本每次预测结果一致。模型能跑通后上线前我给你两道必须做的检查。第一道是分长度查看准确率——把验证集文本按长度分成短文本、中等文本、长文本三组分别计算F1。如果长文本组明显拉胯说明max_len的截断问题没解决好上线后用户的长评论全是盲区。第二道是按类别看混淆矩阵——手工打印出预测错误最多的几个样本逐一读一遍。这些样本如果连人都难以判断那说明标签体系本身存在边界模糊需要业务方介入重新定义类别如果人一眼就能看出标签给错了那说明数据质量有问题清洗环节有漏网之鱼。我在项目里吃过一次亏模型离线F1百分之八十九上线后对产品更新类评论几乎全部分错原因是这类文本里新旧产品名混着出现数据预处理时把产品名当成噪音洗掉了。后来养成的习惯是预处理规则每增删一条都在固定验证集上跑一次回归看分项指标有没有掉——这比任何热门优化技巧都保命。这份工程经验也是我每次拿到新的情感分析源码都会先做一遍的事情希望你也能养成。希望帮到你。本文还有配套的精品资源点击获取