☰
Python新闻标题情感分析实战:从TF-IDF到BERT微调
2026/9/28 1:28:40 网站建设 项目流程

简介:这份资源是面向高校学生与NLP入门者的新闻标题情感分析项目源码,基于Python实现,适合用作课程大作业、毕业设计参考或自然语言处理练手案例。项目围绕中文新闻标题的文本预处理、特征提取与情感倾向判别展开,能帮助读者理解情感分析从数据到模型输出的完整链路。压缩包共6个文件,约420KB,包含1个Python主程序、1个CSV新闻数据集、2张运行效果截图、1份说明文档及1个开源协议文件,结构精简,便于快速阅读与二次修改。目前已有276人学习关注。源码经过严格调试,可直接运行,评审分达到95分以上,读者可据此掌握文本清洗、分词、情感分类等关键实现思路,并参考截图与说明文档完成环境配置与结果验证,适合作为NLP算法实践的起步项目。

1. 新闻标题情感分析:一条被低估的 NLP 落地路径

新闻标题的情感分析,很多人第一反应是“这不就是文本分类吗”。但真做过就知道,标题这种短文本,信息密度极高、上下文极少、反讽和隐喻扎堆,跟长文本情感分析完全是两套打法。一个“涨”字在财经标题里是正向,在“血压涨了”里就是负向;一个问号能把整句话的情感极性翻转。这也是为什么很多团队拿通用情感模型直接套新闻标题,准确率经常卡在 70% 上下上不去。

这个项目要解决的核心问题很具体:给定一条新闻标题,自动判断它是正面、负面还是中性。技术栈是 Python + NLP,属于情感分析里最经典的三分类任务。适合谁做?一是想入门 NLP 但不想碰太抽象任务的开发者,二是手里有新闻数据、想做舆情监控或内容推荐排序的工程同学,三是需要一份能跑通、能改、能交作业的完整源码的在校学生。它不涉及多模态,也不碰视频人物情感分析那套重资产方案,就是纯文本、纯 Python,一台普通笔记本就能从头跑到尾。

我见过太多人卡在“装完环境不知道下一步干嘛”或者“模型跑出来 F1 只有 0.6 不知道问题在哪”。这篇就按我实际做这类项目的顺序,把数据、模型、训练、调参、排错一条线讲透,让你拿到源码后不是“能跑就行”,而是知道每个参数动了会发生什么。

2. 从原始标题到模型输入:数据清洗与标注的四个关键决策

2.1 新闻标题的脏数据长什么样,先看一眼再动手

拿到一份新闻标题数据集,别急着pd.read_csv然后train_test_split。先做一件事:把前 200 条标题打印出来,逐条读。你会看到这些典型问题——HTML 实体残留(&、")、全角半角混用(“A股”和“A股”同时出现)、多余空格和换行符、来源后缀(“——XX日报”)、以及同一事件被拆成多条近似标题。这些不处理,模型学到的就是噪声。

清洗的目标不是“干净”,而是“一致”。我一般按这个顺序走:去 HTML 标签和实体、统一全半角、去掉来源后缀和编辑署名、去除首尾空白和连续空格、过滤长度小于 4 个字的标题。长度过滤这条容易被忽略,但“震惊!”这种标题对三分类任务就是纯噪声,留着只会拉低模型区分度。

import re import html import unicodedata def clean_title(title: str) -> str: # 1. 反转义 HTML 实体,如 &amp; -> & title = html.unescape(title) # 2. 去掉 HTML 标签残留 title = re.sub(r'<[^>]+>', '', title) # 3. 全角转半角,统一字符宽度 title = unicodedata.normalize('NFKC', title) # 4. 去掉来源后缀,如 "——XX日报" "| XX网" title = re.sub(r'[—\-||]{1,2}\s*[\u4e00-\u9fa5]{2,8}(日报|晚报|网|新闻|客户端)$', '', title) # 5. 去掉编辑署名括号 title = re.sub(r'[((]\s*(编辑|记者|来源)[::]?.*?[))]', '', title) # 6. 压缩连续空白 title = re.sub(r'\s+', ' ', title).strip() return title # 使用示例 raw = "&amp;A股三大指数集体收涨——证券日报" print(clean_title(raw)) # 输出: A股三大指数集体收涨

这段代码里,unicodedata.normalize('NFKC', ...)是关键一步,它把全角字母、数字、标点统一成半角,避免“A”和“A”被当成两个不同 token。来源后缀的正则用了[\u4e00-\u9fa5]{2,8}来匹配 2 到 8 个汉字的媒体名,太短会误伤正文,太长会漏掉。清洗完记得统计一下长度分布,如果清洗后平均长度低于 8 个字,说明数据源本身质量有问题,得回头找数据。

2.2 标注体系:三分类还是五分类,取决于你的下游任务

情感分析常见的标注体系有三分类(正面/负面/中性)和五分类(强烈正面/正面/中性/负面/强烈负面)。新闻标题场景我强烈建议从三分类起步。原因很直接:新闻标题的情感表达本身就偏克制,五分类里“强烈正面”和“正面”的边界在标题这种短文本上几乎不可分,标注一致性会掉到 0.6 以下,模型学到的就是标注者的随机噪声。

三分类的标注规则要提前写死,不能边标边改。我的做法是给标注同学一份规则表:正面 = 标题传达出积极结果、增长、改善、赞扬;负面 = 传达出损失、下降、冲突、批评;中性 = 纯事实陈述、无明确情感倾向、或正负情感同时出现且强度相当。规则表里要配至少 20 个边界案例,比如“某公司股价持平”是中性,“某公司股价止跌”是正面(隐含改善),“某公司股价再度下跌”是负面。边界案例不配够,后面标注一致性没法保证。

如果你拿到的源码里已经带了标注数据,先做一件事:抽样 100 条,自己按规则重新标一遍,算一下和你理解的标注规则的一致率。低于 85% 的话,要么规则没写清楚,要么数据本身有问题,这时候直接训练模型就是浪费算力。

2.3 分词与向量化:为什么我不用默认的 jieba 模式

中文 NLP 绕不开分词。新闻标题里专有名词多、新词多,jieba 默认的精确模式经常把“碳中和”切成“碳/中和”,把“元宇宙”切成“元/宇宙”。我的做法是加载自定义词典,把领域高频词加进去,同时开启HMM=True让 jieba 对新词有一定发现能力。

import jieba # 加载领域词典,每行一个词 jieba.load_userdict("news_dict.txt") # 内容示例: 碳中和\n元宇宙\n专精特新\n def tokenize(title: str) -> list: # 精确模式 + HMM 新词发现 tokens = jieba.lcut(title, cut_all=False, HMM=True) # 过滤单字和停用词(新闻标题里单字多数是噪声) stopwords = set(["的", "了", "在", "是", "和", "与", "为", "被", "把"]) return [t for t in tokens if len(t) > 1 and t not in stopwords] print(tokenize("碳中和概念股集体走强")) # ['碳中和', '概念股', '集体', '走强']

过滤单字这条规则在新闻标题上效果明显,因为标题用词偏正式,单字多数是虚词或量词,保留下来只会增加特征维度但不增加区分度。停用词表不用太大,新闻标题里真正需要过滤的就那十几个高频虚词,停用词表开太大反而会把“不”“没”这种情感翻转词误杀。

向量化阶段,TF-IDF 和词向量各有适用场景。数据量小于 5 万条时,TF-IDF + 线性模型(如 LogisticRegression 或 SVM)往往是性价比最高的方案,训练快、可解释、调参直观。数据量超过 10 万条且追求更高上限时,再上预训练模型(如 BERT 中文版)做微调。源码里如果两种都提供了,先用 TF-IDF 跑通全流程,确认数据管道没问题,再换 BERT 对比提升幅度。

2.4 训练集/验证集/测试集的切分陷阱

新闻标题数据有个特点:同一事件的多条标题会高度相似。如果随机切分,训练集和测试集里会出现“同一事件的不同表述”,模型在测试集上的表现会被高估。我吃过这个亏——随机切分下 F1 到了 0.89,上线后真实数据只有 0.72,差距全来自数据泄漏。

正确做法是按事件 ID 或时间切分。如果数据里有事件 ID,按事件 ID 分组切分,保证同一事件的所有标题只出现在一个集合里。如果没有事件 ID,按时间切分:用较早的数据训练,较晚的数据测试。时间切分更贴近真实上线场景,但要注意时间跨度不能太短,否则测试集覆盖的事件类型不全。

import pandas as pd from sklearn.model_selection import GroupShuffleSplit df = pd.read_csv("news_titles.csv") # 列: title, label, event_id # 按 event_id 分组切分,避免同一事件泄漏 gss = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42) train_idx, test_idx = next(gss.split(df, groups=df["event_id"])) train_df = df.iloc[train_idx] test_df = df.iloc[test_idx] print(f"训练集 {len(train_df)} 条,测试集 {len(test_df)} 条")

GroupShuffleSplit的groups参数传入事件 ID 列,它保证同一组的样本不会同时出现在训练和测试集中。random_state固定住,方便复现。如果数据里没有事件 ID,至少按标题的字符重叠度做聚类,把高重叠的标题分到同一组,再切分。这一步多花十分钟,能帮你避免后面“模型效果虚高”的误判。

3. 模型选型与训练:从 TF-IDF 基线到 BERT 微调的完整链路

3.1 先跑一个 TF-IDF + 线性模型基线,别一上来就 BERT

很多源码项目为了“看起来高级”,直接上 BERT,结果训练慢、显存不够、调参复杂,新手跑两天跑不出结果就放弃了。我的习惯永远是先跑一个 TF-IDF + LogisticRegression 基线,半小时内拿到第一组指标。这个基线的作用不是最终上线,而是验证数据管道、标注质量、切分逻辑是否正常。如果基线 F1 只有 0.5,换 BERT 也救不回来,问题在数据和标注上。

from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.linear_model import LogisticRegression from sklearn.pipeline import Pipeline from sklearn.metrics import classification_report # 用清洗和分词后的文本 train_texts = train_df["title"].apply(lambda x: " ".join(tokenize(clean_title(x)))) test_texts = test_df["title"].apply(lambda x: " ".join(tokenize(clean_title(x)))) pipeline = Pipeline([ ("tfidf", TfidfVectorizer( max_features=20000, # 控制特征维度,防止过拟合 ngram_range=(1, 2), # 一元和二元词组,捕捉"不涨"这类组合 min_df=3, # 至少出现3次才保留,过滤稀有词 max_df=0.9, # 出现在90%以上文档的词丢弃,过滤通用词 sublinear_tf=True # 对TF取对数,抑制高频词权重 )), ("clf", LogisticRegression( C=1.0, # 正则强度,越大越容易过拟合 max_iter=1000, class_weight="balanced" # 类别不均衡时自动加权 )) ]) pipeline.fit(train_texts, train_df["label"]) preds = pipeline.predict(test_texts) print(classification_report(test_df["label"], preds, digits=4))

ngram_range=(1, 2)在新闻标题上很重要,因为“不涨”“未跌”“超预期”这类二元组合的情感极性跟单字完全不同。min_df=3和max_df=0.9是一对过滤参数,前者去掉太稀有的噪声词,后者去掉“的”“了”这种几乎每篇都有的词。sublinear_tf=True让词频的影响变成对数增长,避免某个词出现次数多就主导特征权重。class_weight="balanced"在负面样本偏少时特别有用,不加的话模型会倾向于预测多数类。

跑完看classification_report,重点看三个类的 F1 是否均衡。如果中性类 F1 明显低于正负类,说明中性样本的标注边界模糊,需要回头检查标注规则。如果负面类召回率特别低,说明负面样本太少或特征不明显,考虑加数据或调整class_weight。

3.2 BERT 微调:什么时候值得上,怎么设参数

当 TF-IDF 基线 F1 稳定在 0.80 以上,且你有 GPU 资源时,可以上 BERT 微调。中文新闻标题场景我一般选bert-base-chinese或hfl/chinese-roberta-wwm-ext,后者在中文任务上通常略好。微调的关键参数就几个:学习率、batch size、最大序列长度、训练轮数。

from transformers import BertTokenizer, BertForSequenceClassification, Trainer, TrainingArguments import torch from torch.utils.data import Dataset class NewsTitleDataset(Dataset): def __init__(self, texts, labels, tokenizer, max_len=64): 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): encoding = self.tokenizer( self.texts[idx], max_length=self.max_len, padding="max_length", truncation=True, return_tensors="pt" ) return { "input_ids": encoding["input_ids"].squeeze(), "attention_mask": encoding["attention_mask"].squeeze(), "labels": torch.tensor(self.labels[idx], dtype=torch.long) } model_name = "hfl/chinese-roberta-wwm-ext" tokenizer = BertTokenizer.from_pretrained(model_name) model = BertForSequenceClassification.from_pretrained(model_name, num_labels=3) train_dataset = NewsTitleDataset(train_df["title"].tolist(), train_df["label"].tolist(), tokenizer) test_dataset = NewsTitleDataset(test_df["title"].tolist(), test_df["label"].tolist(), tokenizer) training_args = TrainingArguments( output_dir="./results", num_train_epochs=3, # 新闻标题数据量通常1-5万,3轮足够 per_device_train_batch_size=32, # 显存不够就降到16或8 per_device_eval_batch_size=64, learning_rate=2e-5, # BERT微调经典学习率,别超过5e-5 warmup_ratio=0.1, # 前10%步数做学习率预热 weight_decay=0.01, evaluation_strategy="epoch", save_strategy="epoch", load_best_model_at_end=True, metric_for_best_model="f1_macro" ) trainer = Trainer( model=model, args=training_args, train_dataset=train_dataset, eval_dataset=test_dataset, compute_metrics=lambda p: {"f1_macro": f1_score(p.label_ids, p.predictions.argmax(-1), average="macro")} ) trainer.train()

max_len=64对新闻标题足够,大部分标题在 30 个字以内,64 已经覆盖 99% 的样本,设太大只会浪费显存。learning_rate=2e-5是 BERT 微调的甜点值,设到 5e-5 以上容易在训练后期震荡,设到 1e-5 以下收敛太慢。warmup_ratio=0.1让学习率从 0 线性升到目标值再衰减,避免一开始就大步长破坏预训练权重。load_best_model_at_end=True配合metric_for_best_model="f1_macro"保证最终用的是验证集上最好的那个 checkpoint,而不是最后一轮的。

如果显存不够,优先降batch_size,其次降max_len,最后才考虑换更小的模型。batch_size降到 8 还能跑的话,用梯度累积(gradient_accumulation_steps=4)模拟大 batch 效果,比直接换模型划算。

3.3 类别不均衡的处理:新闻标题里负面样本往往最少

新闻标题数据有个普遍现象:中性最多,正面次之,负面最少。负面样本可能只占 10% 到 15%。直接训练的话,模型会倾向于把边界样本判成中性或正面,负面召回率很低。处理方式有三档:轻度过采样、损失函数加权、Focal Loss。

轻度过采样最简单,把负面样本复制到正负样本比例 1:2 左右,别复制到 1:1,否则负面类过拟合严重。损失函数加权在 BERT 微调里通过class_weight传给CrossEntropyLoss,权重设为类别频率的倒数。Focal Loss 适合负面样本特别少(低于 5%)且难分样本多的情况,但它引入了两个额外超参,调参成本高,不到万不得已不用。

from torch import nn # 计算类别权重 class_counts = train_df["label"].value_counts().sort_index() total = class_counts.sum() weights = total / (len(class_counts) * class_counts) class_weights = torch.tensor(weights.values, dtype=torch.float).to("cuda") # 自定义 Trainer 的损失函数 class WeightedTrainer(Trainer): def compute_loss(self, model, inputs, return_outputs=False): labels = inputs.pop("labels") outputs = model(**inputs) logits = outputs.logits loss_fn = nn.CrossEntropyLoss(weight=class_weights) loss = loss_fn(logits, labels) return (loss, outputs) if return_outputs else loss

weights的计算逻辑是:总样本数除以类别数再除以每个类别的样本数,得到每个类别的权重。样本少的类别权重高,损失函数对它的惩罚大。这个权重别设得太极端,如果负面类权重是中性类的 10 倍以上,模型会过度预测负面,精确率暴跌。一般控制在 3 倍以内比较稳。

4. 避坑与排查:新闻标题情感分析里最容易翻车的五个地方

4.1 现象:验证集 F1 很高,上线后效果断崖式下跌

原因:数据泄漏。最常见的是随机切分导致同一事件的多条标题同时出现在训练集和测试集,模型记住了事件关键词而不是情感模式。另一个原因是清洗规则在训练和推理阶段不一致,比如训练时去了来源后缀,推理时没去,模型看到的输入分布变了。

解决:按事件 ID 或时间切分数据,确保同一事件只出现在一个集合。把清洗逻辑封装成一个函数,训练和推理都调用同一个函数,不要在两处各写一遍。上线前用一批完全没参与训练的新数据做一次盲测,盲测 F1 和验证集 F1 差距超过 5 个点就要查数据泄漏。

4.2 现象:模型把“不涨”判成正面,“不跌”判成负面

原因:分词把“不”和“涨”切开了,TF-IDF 里“涨”的权重是正面的,“不”的权重没学到或者被停用词表过滤了。BERT 虽然能处理否定,但如果微调数据里否定样本太少,它也学不好。

解决:TF-IDF 方案里把ngram_range设成(1, 2)甚至(1, 3),让“不涨”“没有下跌”作为整体特征进入模型。停用词表里绝对不能加“不”“没”“无”“非”这些否定词。BERT 方案里检查训练数据中否定样本的比例,如果低于 5%,手动补充一批否定样本再微调。

4.3 现象:中性类 F1 始终上不去,模型把大量中性判成正面或负面

原因:中性类的标注边界最模糊。标注者面对“某公司发布新品”这种标题,有人标中性,有人标正面(因为“发布新品”隐含积极)。标注一致性低,模型学到的就是矛盾信号。

解决:重新定义中性类的标注规则,把“纯事实陈述且无情感词”作为中性标准,有情感词的一律归入正或负。找两个标注者独立标 200 条,算 Cohen's Kappa,低于 0.7 就重新培训标注规则。模型侧可以尝试把三分类拆成两个二分类:先判“有无情感”,再判“正或负”,中性类就是第一个分类器判为无情感的样本。

4.4 现象:训练 loss 正常下降,但验证集 F1 波动很大,每次跑结果不一样

原因:随机种子没固定。Python 的random、NumPy 的np.random、PyTorch 的torch.manual_seed、CUDA 的torch.cuda.manual_seed_all都要设。另外DataLoader的shuffle=True如果不设generator,每次 epoch 的顺序也不同。

解决:在代码最开头统一设种子,并且把cudnn.deterministic设为True。注意设了deterministic之后训练会慢一些,但结果可复现。如果验证集 F1 波动超过 3 个点,还要检查是不是学习率太大导致在最优解附近震荡,把learning_rate降一半再试。

import random import numpy as np import torch def set_seed(seed=42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False set_seed(42)

torch.backends.cudnn.benchmark = False和deterministic = True一起用才能保证卷积算法选择也是确定的。如果训练速度实在不能忍,可以只设benchmark = True但接受结果有小幅波动,前提是波动范围在可接受区间内。

4.5 现象:推理时单条预测正常,批量预测结果错乱

原因:批量推理时 padding 方式不对。tokenizer的padding="max_length"会把所有序列补到固定长度,但如果attention_mask没正确传给模型,模型会把 padding 的 0 也当成真实 token 处理。另一个常见原因是 batch 内样本顺序和返回结果顺序没对齐。

解决:批量推理时用padding=True让 tokenizer 动态补到当前 batch 的最长序列,同时确保attention_mask传给模型。返回结果后用argmax拿到预测类别,按输入顺序组装,不要用多线程打乱顺序。如果用了DataLoader,设shuffle=False并记录每个样本的索引,预测完按索引还原。

def batch_predict(texts, model, tokenizer, batch_size=32): model.eval() all_preds = [] for i in range(0, len(texts), batch_size): batch = texts[i:i+batch_size] encoding = tokenizer( batch, padding=True, # 动态补到batch内最长 truncation=True, max_length=64, return_tensors="pt" ) with torch.no_grad(): outputs = model(**encoding) preds = outputs.logits.argmax(dim=-1).cpu().numpy() all_preds.extend(preds.tolist()) return all_preds

padding=True和padding="max_length"的区别在于前者按 batch 内最长序列补,后者按固定长度补。推理阶段用padding=True更高效,但要注意attention_mask必须传给模型,否则 padding 位置会被当成真实 token。torch.no_grad()关掉梯度计算,推理速度能快 30% 以上。

5. 把模型用起来:批量推理、置信度过滤与一个提升精度的小技巧

模型训练完只是半成品,真正产生价值是在推理阶段。新闻标题情感分析的推理有两个工程细节值得单独说:批量处理和置信度过滤。

批量处理前面已经给了代码,核心就是padding=True+attention_mask+no_grad。这里补一个实用参数:batch_size在推理时不用像训练时那么保守,显存允许的话设到 64 甚至 128,吞吐量能翻倍。但要注意,如果标题长度差异很大,动态 padding 下 batch 内最长序列会决定显存占用,所以按长度分桶再批量推理更高效。分桶逻辑很简单:先按标题长度排序,再按 batch_size 切块,每块内长度接近,padding 浪费少。

置信度过滤是另一个容易被忽略的点。模型对每条标题会输出三个类别的概率,取最大概率作为预测类别。但最大概率低于某个阈值时,说明模型“拿不准”,这时候强行给一个类别不如标记为“待人工复核”。我一般把阈值设在 0.6 到 0.7 之间,低于阈值的样本走人工,高于阈值的自动处理。这样能在不显著增加人工量的前提下,把自动处理的准确率再拉高 2 到 3 个点。

import torch.nn.functional as F def predict_with_confidence(texts, model, tokenizer, threshold=0.65): model.eval() results = [] for i in range(0, len(texts), 32): batch = texts[i:i+32] encoding = tokenizer(batch, padding=True, truncation=True, max_length=64, return_tensors="pt") with torch.no_grad(): logits = model(**encoding).logits probs = F.softmax(logits, dim=-1) max_probs, preds = probs.max(dim=-1) for text, pred, conf in zip(batch, preds.tolist(), max_probs.tolist()): if conf >= threshold: results.append({"text": text, "label": pred, "confidence": round(conf, 4)}) else: results.append({"text": text, "label": "待复核", "confidence": round(conf, 4)}) return results

F.softmax把 logits 转成概率分布,max(dim=-1)同时拿到最大概率和对应类别。阈值 0.65 是个经验值,你可以根据业务对准确率和覆盖率的权衡来调:阈值调高,自动处理量减少但准确率上升;阈值调低,自动处理量增加但可能混入更多错误。建议在验证集上画一条阈值-准确率曲线,找到准确率开始明显下降的拐点作为阈值。

最后说一个提升精度的小技巧:标题拼接。新闻标题往往不是孤立存在的,它可能带副标题、栏目名、或者同一事件的前序标题。如果数据里有这些信息,把它们拼接到主标题后面再送进模型,能给模型更多上下文。拼接格式我一般用[主标题] [SEP] [副标题],BERT 能自然处理这种分隔。实测在财经和体育新闻上,拼接副标题能带来 1 到 2 个点的 F1 提升。但注意别拼太多,超过max_len截断反而丢信息。

我自己做这类项目的习惯是:先把 TF-IDF 基线跑通,确认数据管道没问题,再上 BERT 微调,最后用置信度过滤和标题拼接做后处理。每一步都留一个可复现的脚本和固定的随机种子,这样下次换数据或换模型时,能快速定位是哪个环节出了问题。希望帮到你。

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

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

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

立即咨询