
简介一套基于预训练模型BERT与BERT-wwm的新闻情感分析系统Python源码包面向计算机相关专业学生与开发者可用于文本分类、情感分析等课程设计、毕业设计或项目演示。资源共128个文件以70个py脚本、5个sh训练脚本、44个pyc缓存、4个txt说明、3个csv数据及md项目说明为主压缩包仅2.72MB结构清晰包含data数据目录、pretrained_model预训练模型目录、backup-models自动存档目录等。项目已支持roberta_wwm_ext_large、roberta_large等预训练模型微调并提供kfold数据划分、数据信息查看、多模型投票ensemble以及长文本分段输入GRU拼接训练策略可有效降低显存占用适合入门进阶及二次开发。目前已有255人学习浏览可直接作为新闻情感分析任务的完整实现方案方便对照训练流程快速上手并扩展至其他分类场景。1. 从两分类到多分类这套BERT新闻情感分析代码真正值得拆的地方拿到这份基于BERT、BERT-wwm的新闻情感分析系统源码时我本来以为又是一套套了transformers壳的简单微调脚本。真正读完才发现它把文本分类任务里最容易被忽略的几件事都做了数据kfold划分、多种预训练模型切换、长文本分段截断与GRU拼接、多模型投票ensemble、训练结果自动存档。尤其那个将文本截成k段分别输入语言模型再用GRU拼接的设计直接解决了BERT类模型在长新闻文本上max_length不够用、显存随长度平方级增长的痛点。整套代码适合做课程设计、毕业设计也适合想从单模型微调走向完整pipeline的工程师当参考骨架。下面按数据准备、模型改造、训练调参、结果融合的顺序把关键逻辑拆开讲附带可直接落地的命令和参数解释。2. 数据探查与kfold划分先把训练集结构摸清楚再动手2.1 分析数据分布class分布不均会导致什么数据集里包含train.csv、test.csv、submit_example.csv。项目里提供了analysis.py脚本进入data目录直接运行cd data python analysis.py这个脚本会打印训练集的样本总数、正负样本比例、文本长度分布等信息。新闻情感分析通常是二分类正面/负面但实际数据往往存在类别不平衡比如正面新闻占70%负面占30%。如果不做处理模型会倾向于把所有样本预测为多数类AUC可能虚高但实际效果差。我一般会额外看一眼文本长度分布。BERT的max_seq_length通常设置为128或256但新闻文本动辄几百上千字如果直接截断会丢失大量关键信息。这也是这份代码采用split机制的原因——把长文本切成多段每段分别过BERT再用GRU聚合。分析脚本除了看分布还应该统计出长度超过max_seq_length * split_num的样本占比这个数字直接决定你需不需要提高split_num。2.2 preprocess.py的标签映射与kfold逻辑python preprocess.py这个脚本做了两件事一是把标签从字符串映射为id二是把训练集分成k折。默认是二分类如果要改成多分类比如正面、负面、中性三类需要修改preprocess.py里的标签列表。核心代码逻辑# preprocess.py 关键片段 labels [0, 1] # 二分类改成 [0, 1, 2] 即为三分类 label2id {label: i for i, label in enumerate(labels)} id2label {i: label for label, i in label2id.items()} # 划分kfold from sklearn.model_selection import StratifiedKFold skf StratifiedKFold(n_splits5, shuffleTrue, random_state42)这里有个细节用的是StratifiedKFold而不是普通的KFold目的就是保证每一折的正负样本比例和原始数据集一致。如果原始数据不平衡普通kfold会让某一折恰好全是负样本训练时loss震荡。改成自定义数值后训练时通过--kfold_index指定用哪一折做验证集其余做训练集。2.3 数据预处理时容易忽略的坑处理新闻数据时别忘了清洗HTML标签、特殊符号、连续空白字符。代码里如果没做建议在preprocess.py里补一步import re def clean_text(text): text re.sub(r[^], , text) # 去HTML标签 text re.sub(r\s, , text) # 合并空白 return text.strip()另外新闻文本往往带有日期、记者名、来源等无关信息这些噪声会干扰模型学习情感语义。可以按位置过滤掉开头和结尾的固定模板或者直接用正则把类似新华社电记者xxx报道的片段删除。不处理的话模型可能学到的是这篇新闻来自哪个记者而不是情感倾向。3. 分段时间建模BERT-wwm为主GRU拼接解决长文本3.1 为什么要截断成k段而不是直接padding到512BERT类预训练模型的输入长度上限通常是512个tokenXLNet是1024。新闻文本常常超过这个限制。传统的做法是直接截断前512或后512但新闻的关键信息可能分布在全文。这份代码的做法是把文本按长度切成k段每段长度不超过max_seq_length然后分别输入BERT得到k个句向量后再用GRU把这些句向量按顺序拼接起来最后接分类层。这样做的好处很明显显存占用与max_seq_length近似平方关系与k近似线性关系。如果设置max_seq_length128k4实际能处理的文本长度是512但显存占用远小于一次性输入512 token。每段文本都能保留局部语义不会因为截断丢失尾部关键信息。顶层GRU能够建模段落之间的顺序关系。我实际测试过这种方式在长文本分类上通常比简单截断高24个百分点的准确率代价是训练时间约为原来的k倍。3.2 run_bert.py里模型结构如何拼装模型前向传播的关键部分# run_bert.py 模型结构伪代码 input_ids input_ids.view(batch_size, split_num, -1) # 把输入按split_num分开 all_hidden_states [] for i in range(split_num): segment_input input_ids[:, i, :] output bert(segment_input)[0][:, 0, :] # 取[CLS]向量 all_hidden_states.append(output) sequence_output torch.stack(all_hidden_states, dim1) # (batch, k, hidden) gru_output, _ self.gru(sequence_output) logits self.classifier(gru_output[:, -1, :]) # 取最后一个时间步这里有两个细节值得注意取的是每段BERT输出的[CLS]向量而不是平均池化。[CLS]向量经过预训练阶段的NSP任务本身就包含了句子级语义。GRU取的是最后一个时间步的输出因为最后一个时间步理论上聚合了前面所有段的信息。bidirectionalFalse因为新闻情感是整体倾向不需要反向信息。如果你的数据是短文本split_num设置为1的话GRU层就成了多余的恒等映射。这时候完全可以把GRU层去掉直接用BERT的[CLS]向量接分类层减少参数量训练更快。3.3 修改标签数、类别loss的完整位置项目说明里明确要求修改以下文件完成多分类适配preprocess.py标签列表扩展run_bert.pylabel数量、类别数、类别losscombine.py最终结果合并时也要对应修改run_bert.py里的改动num_labels 2 # 改成3 self.classifier nn.Linear(config.hidden_size, num_labels) # loss计算二分类常用BCEWithLogitsLoss多分类用CrossEntropyLoss if num_labels 2: loss_fct nn.BCEWithLogitsLoss() else: loss_fct nn.CrossEntropyLoss()注意二分类可以用Sigmoid输出单节点也可以用Softmax输出双节点两者在数学上等价但训练曲线不同。代码里用的是num_labels个节点的Softmax这个对于二分类是没问题的只是换成BCELoss后收敛速度通常更快。我倾向于二分类也用CrossEntropyLoss方便切换到多分类时不用改loss代码。3.4 实际输入长度与batch size计算项目说明里已经很明确实际长度 max_seq_length * split_num 实际batch size per_gpu_train_batch_size * GPU数量假设你设置max_seq_length128split_num4实际最长能处理512个token的新闻。如果你的数据集较长可以适当调大split_num但不要超过6否则每段过短语义被切碎。一个直接的换算示例# 4卡GPU训练 per_gpu_train_batch_size4 # 实际batch size 4 * 4 16 # 单卡训练要保持同样的batch size per_gpu_train_batch_size16 # 但显存不够怎么办用梯度累积 gradient_accumulation_steps4 # 每次实际前向batch为4累积4次更新一次等价于batch size16注意梯度累积配合学习率调整。累积步数增加后模型参数更新频率降低如果原学习率是5e-5累积4步时学习率可以适当提高到1e-4但不要超过太多否则loss会震荡。4. 训练脚本参数详解与单卡/多卡适配4.1 run_xxx.sh里每个参数到底控制什么项目里有run_roberta_wwm_ext_large.sh、run_bert_wwm.sh等bash脚本。以其中一个为例# run_bert_wwm.sh export CUDA_VISIBLE_DEVICES0,1,2,3 # 使用4张GPU python run_bert.py \ --model_type bert \ --model_name_or_path ./pretrained_model/bert_wwm_ext \ --do_train \ --do_eval \ --data_dir ./data \ --kfold_index 0 \ --max_seq_length 128 \ --split_num 4 \ --per_gpu_train_batch_size 4 \ --per_gpu_eval_batch_size 8 \ --gradient_accumulation_steps 2 \ --learning_rate 5e-5 \ --num_train_epochs 3 \ --train_steps 5000 \ --output_dir ./outputs/bert_wwm参数说明参数含义调参建议model_typebert / roberta / xlnet不同模型对应不同的tokenizer和后处理逻辑model_name_or_path预训练模型目录必须包含config.json、pytorch_model.bin、vocab.txtkfold_index用第几折做验证集训练完5折后可以交叉验证取平均分数max_seq_length每段最大token数建议128或256太小损失语义太大显存爆炸split_num文本切分数按最长文本估算保证能覆盖90%以上样本gradient_accumulation_steps梯度累积步数显存不够时提高并同步提高train_stepstrain_steps总训练步数如果配合gradient_accumulation_steps要按比例放大4.2 为什么train_steps不是由epoch直接决定这个代码里同时有num_train_epochs和train_steps两个参数实际训练以train_steps为准。这么做的好处是便于控制训练时长但要注意如果数据集只有5872条样本batch size4那么一个epoch大约1468步。设train_steps5000大约训练3.4个epoch对BERT类模型来说足够收敛再多容易过拟合。如果显存不够你把per_gpu_train_batch_size从4降到了2同时设gradient_accumulation_steps2实际batch size还是4但每个step的更新频率慢了一倍。这时如果还保持train_steps5000那么模型实际见过的样本数少了等价于只训练了1.7个epoch。正确做法是train_steps10000保持总的参数更新次数不变或者保持train_steps不变但调低学习率。判断标准是看训练日志09/06/2019 21:03:41 - INFO - __main__ - Num examples 5872 09/06/2019 21:03:41 - INFO - __main__ - Batch size 4 09/06/2019 21:03:41 - INFO - __main__ - Num steps 5000如果Num examples代表的是单卡样本数Batch size是单卡批大小那计算实际epoch数时要把GPU数量和梯度累积都乘进去实际epoch (train_steps * per_gpu_train_batch_size * num_gpus * gradient_accumulation_steps) / num_examples代入示例5000 * 4 * 4 * 1 / 5872 ≈ 13.6 epoch这个数字明显偏大。所以如果这是4卡时的配置单卡训练不变train_steps会训练得更慢但样本遍历更多容易过拟合。建议单卡时将train_steps减半甚至更多或者直接改用num_train_epochs控制。4.3 多卡训练的同步方式代码用的应该是PyTorch的DataParallel或DistributedDataParallel。DataParallel简单但效率低显存分配不均DistributedDataParallel效率高是主流做法。如果训练时发现GPU利用率参差不齐特别是0号卡占用明显偏高那就是DataParallel的典型问题。条件允许的话改成python -m torch.distributed.launch --nproc_per_node4 run_bert.py ...注意用launch方式启动时CUDA_VISIBLE_DEVICES的写法要改成对应用法而且代码里需要初始化进程组。项目如果没适配改起来要谨慎别弄坏了原有流程。4.4 训练中loss不下降时的排查顺序遇到loss不降或验证集分数异常按这个顺序排查先看数据预处理后的文件kfold划分后的train.csv里标签和文本是否一一对应有没有NaN值。看tokenizer是否加载成功BERT-wwm用的是中文vocab如果误用了英文BERT的vocab中文会全部变成[UNK]模型什么都学不到。调小学习率BERT微调一般用2e-5到5e-5超过1e-4很容易震荡。检查类别权重如果类别不平衡严重在loss里加上weight参数给少数类更高的权重。weights torch.tensor([1.0, 3.0]) # 负样本权重设高 loss_fct nn.CrossEntropyLoss(weightweights.to(device))5. 多模型投票ensemble与结果存档机制5.1 ensemble_submits里vote融合怎么做项目里提供了ensemble_submits目录用于把多个模型跑出的result.csv进行投票融合。不同预训练模型BERT-wwm、RoBERTa-large、XLNet学到的特征侧重点不同投票融合能显著提升鲁棒性尤其是单模型在某个类别上表现差时多数投票能拉回整体分数。常见做法是硬投票hard voting直接把每个模型对每条样本的预测类别拿出来统计出现次数最多的类别# combine.py 核心逻辑 import pandas as pd from collections import Counter results [] for model_name in [bert_wwm, roberta_large, xlnet]: df pd.read_csv(foutputs/{model_name}/result.csv) results.append(df[predicted_label].values) final_pred [] for i in range(len(results[0])): votes Counter([r[i] for r in results]) final_pred.append(votes.most_common(1)[0][0])如果某些模型明显比其他的准确率高可以改成加权投票权重可以用验证集上的F1分数确定# 加权投票示例 weights {bert_wwm: 0.8, roberta_large: 1.0, xlnet: 0.6} score {} for model_name, pred in zip(model_names, preds): score[pred] score.get(pred, 0) weights[model_name] final_label max(score, keyscore.get)5.2 自动存档和backup-models目录的设计意图训练完成后代码会自动把模型权重、配置文件、输出result.csv保存到backup-models目录并打包成带时间戳的子目录。这个设计很实用方便对比不同参数、不同模型的结果。我建议你在训练每个模型时把对应的运行日志也存一份比如python run_bert.py ... 21 | tee backup-models/bert_wwm_$(date %Y%m%d_%H%M%S).log后面再次调参时对比日志里的loss下降曲线和验证分数比只看最终指标有用得多。5.3 从ensemble结果反推单模型质量问题融合之后如果发现投票结果和某个单模型几乎完全一致说明这个单模型在验证集上占据绝对主导其他模型提供的不同视角不够。这时候不是继续加模型而是回头检查弱模型是否训练充分学习率是否过高导致没收敛kfold_index是否固定在同一折导致数据分布偏差split_num是否过小导致长文本信息大量丢失。我见过一个案例BERT-wwm和RoBERTa分别跑单模型验证F1一个是0.91一个是0.90ensemble后反而掉到0.89。原因是两个模型在大多数样本上预测一致一旦出现分歧恰好那个更准的模型被投票拉偏。这种情况下应该用加权投票而不是简单多数。6. 显存不足时的最后一招动态截断与DDP的坑如果你的显卡只有6G显存设置max_seq_length128、split_num4、batch size4依然OOM可以把batch size降到2配合gradient_accumulation_steps4模拟batch size8的效果。此时记住把train_steps至少提升4倍或者改用epoch控制训练步数。另一个容易忽略的点是验证阶段也可能OOMper_gpu_eval_batch_size可以单独设小一些验证不需要梯度显存占用比训练小但batch太大会把缓存挤爆。代码里如果用了DataParallel还要注意每个GPU上的BatchNorm行为BERT内部没有BatchNorm主要是LayerNorm所以多卡影响不大。但如果后续接的GRU或分类层里用了BatchNorm多卡训练时sync_batchnorm必须开启否则每张卡上的BN统计量各自独立推理时取平均会导致指标下降。用PyTorch时加一行model nn.SyncBatchNorm.convert_sync_batchnorm(model)最后验证模型质量时除了看test集上的accuracy还要单独计算每个类别的precision、recall、F1。新闻情感分析里负面新闻的召回往往比正面新闻低因为负面表达更隐晦。如果负面样本特别少建议用F1而不是accuracy作为模型保存的筛选条件。切换到多分类时先跑一折看看每类样本量少于500条的类别预测极不稳定考虑数据增强比如同义替换、回译或者直接使用预训练模型的MLM能力生成伪样本。投票融合后把最终result.csv里的预测标签分布打印出来和训练集的标签分布对比。如果预测结果里绝大多数都是正面说明模型没有真正区分能力只是在拟合训练集先验分布。这时回头查代码里的do_lower_case设置、中文分词的粒度、以及是否忘了关闭训练时的随机dropout。这些细节排查完项目就能从能跑通变成能交付答辩。本文还有配套的精品资源点击获取