简介这是一个基于预训练模型BERT、BERT-wwm的新闻情感分析系统Python源码包面向计算机相关专业学生作为课程设计、毕业设计或期末大作业也适合入门自然语言处理文本分类的开发者。包内含完整项目代码、说明文档与训练数据集共128个文件以Python脚本70个py、预编译pyc、shell训练脚本、配置说明txt、数据csv及说明md为主整体约2.72MB。项目实现了从数据kfold处理、数据信息查看、预训练模型文本分类支持roberta_wwm_ext_large、roberta_large等到模型集成投票、自动存档的完整流程目录结构清晰包含backup-models、data、pretrained_model、ensemble_submits等模块。已有255人浏览学习适合希望快速上手BERT系列模型做情感分析、需要完整可运行基线方案的读者基于此二次开发或扩展为毕设演示均可。1. 基于预训练模型BERT、BERT-wwm的新闻情感分析系统这套源码到底值不值得跑新闻情感分析一直是NLP落地里最“攒劲”的场景热点事件出来运营想立刻知道舆论是正、是负还是中性媒体小编想判断稿子倾向舆情公司更是不停地给新闻正文打情感标签。以前大家用情感词典准确率不稳换个领域就崩。现在预训练模型BERT和它的升级变体BERT-wwm全词掩码成了标配用项目源码加数据集就能本地训练出可用模型。这篇笔记围绕“python源码项目说明数据集”这条线说的是复现这套系统该做什么以及最值得花时间的几件事。这类项目真正值钱的不是那几行模型调用的代码而是数据清洗、标签映射、训练参数和模型文件管理这些地面功夫。2. 把新闻数据集喂进BERT前清洗、标签检查和文本长度分布很多人拿到源码包第一步就急着跑train.py结果要么报错要么模型loss一直不降。我一般不会直接动训练脚本而是先花一小时把数据集摸一遍。数据决定上限BERT只是帮你逼近这个上限。源码附带的新闻数据集不管是什么格式先明确三件事字段含义、标签分布、文本长度。2.1 大多数新闻情感数据集长什么样从类别字段到标签映射这类数据集最常见的结构是TSV或CSV两列一列是新闻标题或正文一列是情感标签。标签有分正/负二分类的也有带中性的三分类还有极小众的五分类非常负面、负面、中性、正面、非常正面。做技术复现时第一件事不是写模型而是先把标签字段稳定成整数索引。我拿到CSV后会先做一次结构体检import pandas as pd from collections import Counter df pd.read_csv(news_dataset.csv, sep\t) # 很多新闻数据集用\t分隔 print(df.columns.tolist()) print(df.head(3).to_string()) print(shape:, df.shape) # 检查标签取值和数量分布这一步决定loss函数怎么写 label_counter Counter(df[label]) print(label_counter)逻辑说明sep\t是因为很多新闻语料是爬虫导出用制表符分隔比逗号更不易出错——新闻正文里全角逗号、半角逗号到处都是用csv读反而容易裂列。Counter统计每个类别的样本数目的是判断目标是否均衡。如果三类样本比例像7000:6800:6500这种属于可训练范围如果出现10000:2000:500这种就要准备做类别加权或欠采样。参数说明当标签是三分类时模型输出维度设3二分类设2。注意有些数据集里的情感标签写的是中文比如“正向”“负向”“中性”必须要做映射label_map {正向: 0, 负面: 1, 中性: 2} # 如果不想要中性维度可以合并成二分类新闻情感分析很特殊的一点是负面新闻天生容易识别中性文本最难分。因为新闻讲究客观陈述大量政治、财经稿子情感词极少模型很容易把“中性”学成“负面”。这就是后面所有训练策略都要围绕标签分布展开的原因。2.2 BERT自带Tokenizer做子词切分为什么不需要先跑jieba传统文本分类的第一道工序是分词。但使用BERT家族时这一步完全被BertTokenizer替代。BERT用的是WordPiece子词切分中文场景下它的bert-base-chinese权重是整字切分——每个汉字是一个token英文和数字会被切成子词。所以新闻标题里“苹果公司发布iPhone 15”会切成“苹 果 公 司 发 布 i phone 15”这种token序列。我见过有人非要先用jieba.cut()把新闻分词再把词用空格串起来喂给BERT这种做法在中文BERT上属于画蛇添足。因为预训练权重就是按不带分词的文本训练的强行分词等于改变输入分布。真正需要做的事情是清洗脏字符from transformers import BertTokenizer tokenizer BertTokenizer.from_pretrained(bert-base-chinese) def clean_text(text: str) - str: # 新闻爬虫文本经常带网页噪声去掉多余空白和特殊符号 text text.replace(\\u3000, ).replace(\u3000, ) text text.replace(\\xa0, ) # 不断行空格 text .join(text.split()) # 统一连续空白 return text.strip() # 验证tokenizer在正常长句上的输出 sample 专家表示此次降价对市场影响有限消费者仍需理性看待。 tokens tokenizer.tokenize(clean_text(sample)) print(tokens[:20]) print(token数:, len(tokens))逻辑说明clean_text解决的是CSV解析后残留的全角空格和\xa0新闻网页文本常出现这类隐藏字符不过滤会污染token序列。tokenizer.tokenize()返回的是普通字符串列表便于肉眼检查切分是否正常正式训练用encode_plus或__call__得到input_ids。参数说明这里用的bert-base-chinese是中文BERT预训练权重HuggingFace内置下载。它的词表有21128个token足以覆盖简体新闻文本。如果数据集包含大量粤语、英文俚语、表情符号可以考虑换bert-base-multilingual-cased但中文新闻场景下中文权重普遍更好。2.3 构造PyTorch Dataset并统计类别权重先解决类不均衡再谈训练数据预处理最终要变成PyTorch能吃的Dataset结构。一个踩过坑的地方是很多源码里的Dataset返回的是(batch, label)但没有把attention_mask处理好。新闻句子长短不一padding得多的话mask必须同步生成否则模型会把padding当成真实文本严重影响收敛。我一般会这样构造import torch from torch.utils.data import Dataset class NewsDataset(Dataset): def __init__(self, texts, labels, tokenizer, max_len128): self.texts texts self.labels labels self.tokenizer tokenizer self.max_len max_len def __len__(self): return len(self.texts) def __getitem__(self, idx): text clean_text(self.texts[idx]) encoding self.tokenizer.encode_plus( text, max_lengthself.max_len, paddingmax_length, truncationlongest_first, return_tensorspt ) # 把encode_plus返回的张量去掉batch维度否则dataloader会变成三维 item { input_ids: encoding[input_ids].squeeze(0), attention_mask: encoding[attention_mask].squeeze(0), labels: torch.tensor(self.labels[idx], dtypetorch.long) } return item逻辑说明truncationlongest_first是最适合新闻文本的截断策略——从最长的一侧开始删token保住句子主干而不是硬切前128个字。paddingmax_length保证了batch内部形状一致虽然会浪费一些算力但换来的是训练循环里不需要写任何动态padding逻辑新手复现不容易翻车。参数说明max_len128对新闻标题足够新闻正文一般也建议控制在256以内。不是越长越好BERT对512token的注意力矩阵计算复杂度是平方级长度拉长一倍显存占用可能变成四倍。数据体量大时可以先统计所有新闻的token长度取P90作为max_lenlengths [] for text in df[text].tolist()[:20000]: lengths.append(len(tokenizer.encode(text, truncationFalse))) p90 sorted(lengths)[int(len(lengths) * 0.9)] print(P90 token len:, p90)这步能快速判断数据集的文本长度形态。很多新闻正文的P90只有200左右这时max_len设256很划算。类别不均衡则在训练时用weighted sampler或CrossEntropyLoss(weight...)来解决。二分类正负比4:1时固定学习率训练很容易把多数类准确率刷到95%少数类只有40%。我喜欢先把权重传进lossfrom torch.nn import CrossEntropyLoss class_weights torch.tensor([1.0, 2.5, 1.8]) # 根据2.1统计结果调整 criterion CrossEntropyLoss(weightclass_weights)权重设多少不是玄学直接取负样本占比的倒数再归一化。比如负样本是正样本的2.5倍就把负类的权重设为1.0正类设为2.5。3. 在BERT和BERT-wwm之间选型微调流程与关键差别数据集准备好了接下来是模型选型。BERT和BERT-wwm尤其适合新闻情感分析中文新闻里很多高频词“不排除”、“有望”、“风险”是两三个字的词组原版BERT训练时用的是随机单字掩码会把词拆开破坏语义BERT-wwm的Whole Word Masking做法是每次遮住整个中文词模型被迫从更完整的上下文学习词边界。新闻这种讲究庄重语体的文本全词信息往往比单字更利于情感判断。但我必须诚实说BERT-wwm在新闻情感任务上跟BERT的差距经常只有0.5%-1.5%F1并不像论文里那样神话。花时间调max_len和类权重收益可能更大。3.1 加载预训练模型与Tokenizer指定local_files_only防止联网下载中断源码里最常见的是用HuggingFace的BertForSequenceClassification直接加载。这个类自动帮我们把BERT的输出接到分类头上。很多新手在第一次加载时碰到网络问题下载一半中断然后整个环境卡住。我的做法是先用命令行把权重下到本地缓存再在脚本里指定local_files_only进行离线加载。from transformers import BertForSequenceClassification, BertTokenizer model_name hfl/chinese-bert-wwm-ext # BERT-wwm权重中文全词掩码预训练 # 第一次运行时联网下载之后权重会落在本地缓存目录 tokenizer BertTokenizer.from_pretrained(model_name) # 加载模型时可以关掉远程检查避免因为网络抖动反复下载 model BertForSequenceClassification.from_pretrained( model_name, num_labels3, local_files_onlyFalse # 设True后只读缓存没有权重会直接报错 )逻辑说明hfl/chinese-bert-wwm-ext是常用的中文BERT-wwm扩展模型。local_files_onlyFalse时HuggingFace会先查缓存没有才走联网设成True则强制离线适合服务器内网部署时使用。训练阶段保持False没关系推理部署阶段建议在本地先把模型目录复制出来再开local_files_onlyTrue。参数说明num_labels3对应前面的三分类标签。不要手动去改BertForSequenceClassification里的classifier层维度之外的东西预训练权重加载时会自动初始化随机参数直接改模型结构容易导致维度不匹配报错。3.2 分类头设计CLS输出、pooled输出和平均池化该选谁BertForSequenceClassification内部的分类头默认取[CLS]token的最终隐层输出去做线性分类。但实际项目中我经常把pooler_output也拎出来看一眼。HuggingFace的BertModel返回四个东西last_hidden_state、pooler_output、hidden_states、attentions。pooler_output是BERT内部对[CLS]再过一层全连接和tanh后的结果新闻情感分类这种短句场景两者差异不大长文本时pooler_output往往更稳。如果你想自己搭分类头常见做法是import torch.nn as nn class NewsSentimentHead(nn.Module): def __init__(self, bert_model, hidden_size768, num_labels3): super().__init__() self.bert bert_model self.dropout nn.Dropout(0.3) # 新闻文本噪声大dropout比默认0.1要激进 self.fc nn.Linear(hidden_size, hidden_size) self.out nn.Linear(hidden_size, num_labels) self.activation nn.GELU() def forward(self, input_ids, attention_mask): outputs self.bert(input_ids, attention_maskattention_mask) pooled outputs.pooler_output # [batch_size, hidden] pooled self.dropout(pooled) pooled self.fc(pooled) pooled self.activation(pooled) pooled self.dropout(pooled) return self.out(pooled)逻辑说明加一层hidden_size的全连接主要让模型有更强的非线性拟合能力但新闻情感分类不需要复复杂大网络一层就够。dropout提高到0.3是因为新闻文本里低频词、人名机构名很多模型容易快速记住关键词导致验证集掉点。参数说明hidden_size768对应BERT-base隐藏层维度。命题系统若用BERT-large这里应改成1024。源码里如果写死了768换成large权重会报维度错误。3.3 微调训练循环学习率、batch_size、warmup和梯度裁剪训练BERT的细节比模型结构更影响结果。新闻情感分析用BERT微调我踩过最大的坑是把学习率照搬其他CNN项目的1e-3。BERT是预训练参数学习率过大会直接把原有词向量冲乱。稳定区间通常是1e-5到5e-5。下面是一段完整的训练核心循环。from transformers import AdamW, get_linear_schedule_with_warmup learning_rate 2e-5 batch_size 16 epochs 4 warmup_steps int(0.1 * len(train_dataloader) * epochs) optimizer AdamW(model.parameters(), lrlearning_rate, weight_decay0.01) scheduler get_linear_schedule_with_warmup( optimizer, num_warmup_stepswarmup_steps, num_training_stepslen(train_dataloader) * epochs ) model.to(device) for epoch in range(epochs): model.train() total_loss 0.0 for step, batch in enumerate(train_dataloader): batch {k: v.to(device) for k, v in batch.items()} outputs model(**batch) loss outputs.loss # BertForSequenceClassification自带的loss loss.backward() # 新闻长文本容易产生梯度爆炸必须裁剪 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() scheduler.step() optimizer.zero_grad() total_loss loss.item() avg_loss total_loss / len(train_dataloader) print(fepoch {epoch} avg_loss {avg_loss:.4f})逻辑说明outputs.loss是BertForSequenceClassification内部根据num_labels自动计算的交叉熵省去手写criterion。clip_grad_norm_设max_norm1.0是关键长新闻样本会出现个别极端token导致梯度冲高不裁剪loss曲线会突然跳到NaN。scheduler.step()放在optimizer.step()之后这是PyTorch里线性学习率调度器的标准顺序。参数说明batch_size16在单卡12GB显存下基本是上限如果用CPU训练batch_size降到8或4且max_len要相应调小。warmup_steps设为总训练步数的10%能够让模型在前期平稳起步不产生大震荡。3.4 BERT-wwm对比实验同数据、同参数、同随机种子才可信项目说明里既然同时给出BERT和BERT-wwm必然要跑对比实验。最容易犯的错是只换model_name但数据集划分乱了。对比实验必须固定随机种子否则类别不均衡的数据集每次划分出来的训练验证集分布都不同结论毫无意义。我把靠谱的对比做法写清楚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) set_seed(42) # 数据集划分也必须set_seed后再操作在相同训练代码里只改一行model_name bert-base-chinese # 实验组A model_name hfl/chinese-bert-wwm-ext # 实验组B记录对比时不要只看准确率要看每个类别的F1。新闻情感三分类里中性类F1通常最低BERT-wwm的优势恰恰体现在中性类能拉高1-2个点。如果那个数据集中性类占比不到10%对比意义有限这个结论在项目说明里值得写清楚。4. 把训练好的模型变成可用的新闻情感分析系统源码里的最小完整闭环模型训练只是第一步源码包里的“系统”还要能对新的新闻做预测。很多人训练完在Notebook里能出结果但没法集成到业务里往往因为模型文件管理混乱。这一章给出一条最简单的闭环路径。4.1 模型文件家族config.json、pytorch_model.bin、vocab.txt不能拆散微调完成后模型要保存成HuggingFace的标准文件格式。一个常见的翻车是只保存pytorch_model.bin下次加载时报各种维度错。标准保存方法如下save_dir ./news_sentiment_model model.save_pretrained(save_dir) tokenizer.save_pretrained(save_dir)save_pretrained会同时写出三类关键文件news_sentiment_model/ ├── config.json # 模型结构配置记录num_labels3 ├── pytorch_model.bin # 模型权重 ├── vocab.txt # tokenizer词表 └── tokenizer_config.json以后重新加载时只需用同一目录路径HuggingFace会从config.json推断分类头维度不需要再手动传num_labels。配置文件、词表和权重文件三者必须放在同一个目录只拷bin文件是复现系统时最常见的“黑匣子”问题。4.2 封装predict函数tokenizer、模型、标签映射三件套预测函数的正确姿势是让输入永远是原始新闻文本输出中文标签而不是输出索引就完事。下面是带置信度的预测封装。def predict_news(model, tokenizer, text, id2label, device, max_len128): model.eval() encoding tokenizer.encode_plus( clean_text(text), max_lengthmax_len, paddingmax_length, truncationlongest_first, return_tensorspt ) input_ids encoding[input_ids].to(device) attention_mask encoding[attention_mask].to(device) with torch.no_grad(): logits model(input_idsinput_ids, attention_maskattention_mask).logits probs torch.softmax(logits, dim-1) pred_id torch.argmax(probs, dim-1).item() confidence probs[0][pred_id].item() return id2label[pred_id], confidence逻辑说明id2label是预测时最容易被搞错的一环。比如类别索引{ 负面: 0, 正面: 1, 中性: 2 }训练时和预测时用同一个映射字典就不会错位。常见的血泪经验是训练脚本里用LabelEncoder顺手拿到索引但没保存映射关系预测时重新按字母排序于是“正面”和“负面”互换整个系统看起来完全失灵。正确做法是在训练结束后把映射表存成JSON。import json label_map {负面: 0, 正面: 1, 中性: 2} id2label {v: k for k, v in label_map.items()} with open(./news_sentiment_model/label_map.json, w, encodingutf-8) as f: json.dump(id2label, f, ensure_asciiFalse, indent2)4.3 批量预测与结果导出CPU推理太慢时的兜底方案新闻系统通常要批量处理上千条新闻。单条循环predict太慢常见做法是让dataloader一次跑一批再统一写回DataFrame。def predict_batch(model, tokenizer, texts, id2label, device, batch_size32, max_len128): model.eval() results [] for i in range(0, len(texts), batch_size): sub_texts texts[i:i batch_size] encoding tokenizer( [clean_text(t) for t in sub_texts], paddingTrue, # batch内按最长样本padding省算力 truncationlongest_first, max_lengthmax_len, return_tensorspt ) input_ids encoding[input_ids].to(device) attention_mask encoding[attention_mask].to(device) with torch.no_grad(): probs torch.softmax(model(input_idsinput_ids, attention_maskattention_mask).logits, dim-1) preds torch.argmax(probs, dim-1).cpu().tolist() confs probs.max(dim-1).values.cpu().tolist() for idx, j in enumerate(preds): results.append((id2label[j], confs[idx])) return results这里paddingTrue不使用统一max_len而是按batch内最长样本补padding比固定max_len少算不少无效token。如果CPU推理实在慢还有一个更接地气的方案训练时如果没有GPU就用蒸馏过的distilbert-base-chinese或albert-base-chinese但那些不是本系统标题里的预训练模型。从项目落地角度我建议数据量不超过5万条时直接GPU跑一次然后离线缓存预测结果别写成每来一条新闻实时调模型。5. 避坑排查本地复现这个系统的五个高频翻车点这个部分直接给结论每个坑都是我在类似源码包上实际踩过的。复现失败的原因往往不在模型而在环境、数据和代码之间的默契。5.1 现象RuntimeError: CUDA out of memory训练到第三个epoch崩溃原因很多最常见的是max_len512加batch_size16显存被打爆。还有一个隐藏原因是PyTorch默认缓存机制导致虽然batch_size小但上一次batch的显存没释放多次累加后OOM。解决先把batch_size调到8试一次同时把max_len从512降到256。缩小模型不会掉太多效果长文本显存占用是二次方的降一半长度能省将近四倍量。如果还想保住大batch改用梯度累积每4步更新一次优化器等于batch_size不变但实际显存占用只算一个小batch。5.2 现象loss降不下去准确率稳定在50%附近像是随机猜原因标签映射错位尤其是News数据集的label字段包含了-1、0、1这种非从0开始的索引而分类头期望的是0、1、2。还有一个常见原因是数据处理时把字符串标签直接传给了模型训练数据里混入无法转float的值PyTorch在dataloader阶段静默出错但未必崩溃。解决在预处理第一步打印label的unique()值建立新映射统一到0起始。然后单独跑一个batch的前向验证输出维度。如果还不行用tokenizer.decode(input_ids[0])人工看一眼训练样本是不是乱码。5.3 现象训练集loss一直在降验证集F1忽高忽低每一步都不稳定原因验证集太小或者新闻数据集按时间排序后你直接切了前80%训练、后20%验证导致训练和验证分布完全偏移。新闻文本的时间性很强某段时间内高频出现同一类话题模型学的是时间段特征不是情感特征。解决用sklearn.model_selection.train_test_split(stratifyy)按标签分层划分不要按行号硬切。最好用五折交叉验证看稳定区间。验证集至少要有1000条低于500条的验证曲线没有参考价值。5.4 现象把bert-base-chinese换成hfl/chinese-bert-wwm-ext后预测结果和BERT完全一样原因模型加载后没有重新训练或者训练代码里在对比实验时没把model_name传进from_pretrained训练脚本里的模型名写死成了bert-base-chinese。还有一种情况是训练过程没生效前向直接用了预训练权重。解决换权重后先跑一步train检查loss和训练前是否明显不同。如果loss没变查看model.config里是否有model_type标记和_name_or_path确认加载的确实是新权重。这种做法虽然土但能杜绝“换了没生效”的翻车。保存模型时也可以在config.json里加一行自定义字段model_mark防止下次混淆。5.5 现象HuggingFace下载权重时进度条卡死卡在Downloading a file原因国内网络访问HuggingFace服务器不稳或者没有开代理但这里不展开网络设置。最常见的是下载到一半连接断开HuggingFace缓存了不完整文件之后每次加载都报md5 mismatch。解决把下载超时适当调大用环境变量控制或者直接在命令行下载到本地目录后拷贝到自己的模型目录。建议第一次加载时把local_files_onlyFalse确保权重完整落进缓存后续用local_files_onlyTrue离线加载。如果缓存损坏可以删除本地缓存目录里对应模型名重新下载一次。6. 用五折交叉验证和置信度过滤把源码系统调成自己能上手的版本这是整套系统的最后一公里。新闻情感分析项目里单次train/test的准确率有太多偶然性。我用过一次从源码包里直接跑出来的“92%准确率”模型换了真实新闻数据不到60%。原因是验证集和测试集来自同一个月的话题模型过拟合到话题词上。后来我用五折交叉验证重新选epoch顺便加置信度过滤效果才稳下来。做法是把训练数据分成五份每次用四份训练、一份验证记录每个epoch在验证集上的F1。选平均F1最高的epoch作为最终训练epoch。这个过程不要自己写循环直接用sklearn.model_selection.StratifiedKFoldfrom sklearn.model_selection import StratifiedKFold skf StratifiedKFold(n_splits5, shuffleTrue, random_state42) for fold, (train_idx, val_idx) in enumerate(skf.split(df[text], df[label])): # 每个fold重新构建dataset和dataloader train_df df.iloc[train_idx] val_df df.iloc[val_idx] print(ffold {fold}: train {len(train_df)}, val {len(val_df)})跑完后你会得到每个候选epoch的验证F1均值和方差。方差大的模型不要上生产说明对新闻话题变化太敏感。预测阶段加置信度过滤是我落地时的习惯。三分类新闻情感模型常常出现“0.45负面、0.35中性、0.20正面”这种糊在一起的输出强制argmax会乱标。源码提供的predict函数如果不带置信度我会改成def predict_with_threshold(pred_label, confidence, low_conf_threshold0.6): if confidence low_conf_threshold: return 不确定 return pred_label业务侧拿到“不确定”样本先走人工复核比自己强行给负面新闻打错标签省心得多。新闻系统里误报负面舆情和漏报一样危险。最后聊一个习惯我会先把项目说明文档里标注的模型效果在本地用同目录测试集复现一遍达不到说明数值就先查数据划分不急着调参。跑通之后再去做BERT-wwm和BERT的对比实验把实验记录写在代码旁边的experiments/下。这样两周后回看还能知道当初为什么把dropout设成0.3、为什么把warmup设成10%。技术方案的长期价值恰恰在这些可复现的记录里。希望这套折腾能帮到你。本文还有配套的精品资源点击获取