基于BERT的长文本新闻情感分析:分段GRU与模型融合
2026/9/12 22:09:04 网站建设 项目流程

简介:一套基于预训练模型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_splits=5, shuffle=True, random_state=42)

这里有个细节:用的是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到512

BERT类预训练模型的输入长度上限通常是512个token(XLNet是1024)。新闻文本常常超过这个限制。传统的做法是直接截断前512或后512,但新闻的关键信息可能分布在全文。这份代码的做法是:把文本按长度切成k段,每段长度不超过max_seq_length,然后分别输入BERT,得到k个句向量后,再用GRU把这些句向量按顺序拼接起来,最后接分类层。

这样做的好处很明显:

  • 显存占用与max_seq_length近似平方关系,与k近似线性关系。如果设置max_seq_length=128,k=4,实际能处理的文本长度是512,但显存占用远小于一次性输入512 token。
  • 每段文本都能保留局部语义,不会因为截断丢失尾部关键信息。
  • 顶层GRU能够建模段落之间的顺序关系。

我实际测试过,这种方式在长文本分类上通常比简单截断高2~4个百分点的准确率,代价是训练时间约为原来的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, dim=1) # (batch, k, hidden) gru_output, _ = self.gru(sequence_output) logits = self.classifier(gru_output[:, -1, :]) # 取最后一个时间步

这里有两个细节值得注意:

  1. 取的是每段BERT输出的[CLS]向量,而不是平均池化。[CLS]向量经过预训练阶段的NSP任务,本身就包含了句子级语义。
  2. GRU取的是最后一个时间步的输出,因为最后一个时间步理论上聚合了前面所有段的信息。bidirectional=False,因为新闻情感是整体倾向,不需要反向信息。

如果你的数据是短文本,split_num设置为1的话,GRU层就成了多余的恒等映射。这时候完全可以把GRU层去掉,直接用BERT的[CLS]向量接分类层,减少参数量,训练更快。

3.3 修改标签数、类别loss的完整位置

项目说明里明确要求修改以下文件完成多分类适配:

  • preprocess.py:标签列表扩展
  • run_bert.py:label数量、类别数、类别loss
  • combine.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_length=128split_num=4,实际最长能处理512个token的新闻。如果你的数据集较长,可以适当调大split_num,但不要超过6,否则每段过短,语义被切碎。

一个直接的换算示例:

# 4卡GPU训练 per_gpu_train_batch_size=4 # 实际batch size = 4 * 4 = 16 # 单卡训练,要保持同样的batch size per_gpu_train_batch_size=16 # 但显存不够怎么办?用梯度累积 gradient_accumulation_steps=4 # 每次实际前向batch为4,累积4次更新一次,等价于batch size=16

注意:梯度累积配合学习率调整。累积步数增加后,模型参数更新频率降低,如果原学习率是5e-5,累积4步时学习率可以适当提高到1e-4,但不要超过太多,否则loss会震荡。

4. 训练脚本参数详解与单卡/多卡适配

4.1 run_xxx.sh里每个参数到底控制什么

项目里有run_roberta_wwm_ext_large.shrun_bert_wwm.sh等bash脚本。以其中一个为例:

# run_bert_wwm.sh export CUDA_VISIBLE_DEVICES=0,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.txt
kfold_index用第几折做验证集训练完5折后可以交叉验证,取平均分数
max_seq_length每段最大token数建议128或256,太小损失语义,太大显存爆炸
split_num文本切分数按最长文本估算,保证能覆盖90%以上样本
gradient_accumulation_steps梯度累积步数显存不够时提高,并同步提高train_steps
train_steps总训练步数如果配合gradient_accumulation_steps,要按比例放大

4.2 为什么train_steps不是由epoch直接决定

这个代码里同时有num_train_epochstrain_steps两个参数,实际训练以train_steps为准。这么做的好处是便于控制训练时长,但要注意:如果数据集只有5872条样本,batch size=4,那么一个epoch大约1468步。设train_steps=5000大约训练3.4个epoch,对BERT类模型来说足够收敛,再多容易过拟合。

如果显存不够,你把per_gpu_train_batch_size从4降到了2,同时设gradient_accumulation_steps=2,实际batch size还是4,但每个step的更新频率慢了一倍。这时如果还保持train_steps=5000,那么模型实际见过的样本数少了,等价于只训练了1.7个epoch。正确做法是train_steps=10000,保持总的参数更新次数不变,或者保持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的DataParallelDistributedDataParallel。DataParallel简单但效率低,显存分配不均;DistributedDataParallel效率高,是主流做法。如果训练时发现GPU利用率参差不齐,特别是0号卡占用明显偏高,那就是DataParallel的典型问题。条件允许的话改成:

python -m torch.distributed.launch --nproc_per_node=4 run_bert.py ...

注意,用launch方式启动时,CUDA_VISIBLE_DEVICES的写法要改成对应用法,而且代码里需要初始化进程组。项目如果没适配,改起来要谨慎,别弄坏了原有流程。

4.4 训练中loss不下降时的排查顺序

遇到loss不降或验证集分数异常,按这个顺序排查:

  1. 先看数据预处理后的文件:kfold划分后的train.csv里标签和文本是否一一对应,有没有NaN值。
  2. 看tokenizer是否加载成功:BERT-wwm用的是中文vocab,如果误用了英文BERT的vocab,中文会全部变成[UNK],模型什么都学不到。
  3. 调小学习率:BERT微调一般用2e-5到5e-5,超过1e-4很容易震荡。
  4. 检查类别权重:如果类别不平衡严重,在loss里加上weight参数,给少数类更高的权重。
weights = torch.tensor([1.0, 3.0]) # 负样本权重设高 loss_fct = nn.CrossEntropyLoss(weight=weights.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(f'outputs/{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, key=score.get)

5.2 自动存档和backup-models目录的设计意图

训练完成后,代码会自动把模型权重、配置文件、输出result.csv保存到backup-models目录,并打包成带时间戳的子目录。这个设计很实用,方便对比不同参数、不同模型的结果。我建议你在训练每个模型时,把对应的运行日志也存一份,比如:

python run_bert.py ... 2>&1 | 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.90,ensemble后反而掉到0.89。原因是两个模型在大多数样本上预测一致,一旦出现分歧,恰好那个更准的模型被投票拉偏。这种情况下应该用加权投票,而不是简单多数。

6. 显存不足时的最后一招:动态截断与DDP的坑

如果你的显卡只有6G显存,设置max_seq_length=128split_num=4、batch size=4依然OOM,可以把batch size降到2,配合gradient_accumulation_steps=4模拟batch size=8的效果。此时记住把train_steps至少提升4倍,或者改用epoch控制训练步数。另一个容易忽略的点是:验证阶段也可能OOM,per_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。这些细节排查完,项目就能从"能跑通"变成"能交付答辩"。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询