ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Python新闻标题情感分析实战:从TF-IDF到BERT微调

Python新闻标题情感分析实战:从TF-IDF到BERT微调 简介这份资源是面向高校学生与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 实体残留amp;、quot;、全角半角混用“股”和“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;股三大指数集体收涨——证券日报 print(clean_title(raw)) # 输出: A股三大指数集体收涨这段代码里unicodedata.normalize(NFKC, ...)是关键一步它把全角字母、数字、标点统一成半角避免“”和“A”被当成两个不同 token。来源后缀的正则用了[\u4e00-\u9fa5]{2,8}来匹配 2 到 8 个汉字的媒体名太短会误伤正文太长会漏掉。清洗完记得统计一下长度分布如果清洗后平均长度低于 8 个字说明数据源本身质量有问题得回头找数据。2.2 标注体系三分类还是五分类取决于你的下游任务情感分析常见的标注体系有三分类正面/负面/中性和五分类强烈正面/正面/中性/负面/强烈负面。新闻标题场景我强烈建议从三分类起步。原因很直接新闻标题的情感表达本身就偏克制五分类里“强烈正面”和“正面”的边界在标题这种短文本上几乎不可分标注一致性会掉到 0.6 以下模型学到的就是标注者的随机噪声。三分类的标注规则要提前写死不能边标边改。我的做法是给标注同学一份规则表正面 标题传达出积极结果、增长、改善、赞扬负面 传达出损失、下降、冲突、批评中性 纯事实陈述、无明确情感倾向、或正负情感同时出现且强度相当。规则表里要配至少 20 个边界案例比如“某公司股价持平”是中性“某公司股价止跌”是正面隐含改善“某公司股价再度下跌”是负面。边界案例不配够后面标注一致性没法保证。如果你拿到的源码里已经带了标注数据先做一件事抽样 100 条自己按规则重新标一遍算一下和你理解的标注规则的一致率。低于 85% 的话要么规则没写清楚要么数据本身有问题这时候直接训练模型就是浪费算力。2.3 分词与向量化为什么我不用默认的 jieba 模式中文 NLP 绕不开分词。新闻标题里专有名词多、新词多jieba 默认的精确模式经常把“碳中和”切成“碳/中和”把“元宇宙”切成“元/宇宙”。我的做法是加载自定义词典把领域高频词加进去同时开启HMMTrue让 jieba 对新词有一定发现能力。import jieba # 加载领域词典每行一个词 jieba.load_userdict(news_dict.txt) # 内容示例: 碳中和\n元宇宙\n专精特新\n def tokenize(title: str) - list: # 精确模式 HMM 新词发现 tokens jieba.lcut(title, cut_allFalse, HMMTrue) # 过滤单字和停用词新闻标题里单字多数是噪声 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_splits1, test_size0.2, random_state42) train_idx, test_idx next(gss.split(df, groupsdf[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_features20000, # 控制特征维度防止过拟合 ngram_range(1, 2), # 一元和二元词组捕捉不涨这类组合 min_df3, # 至少出现3次才保留过滤稀有词 max_df0.9, # 出现在90%以上文档的词丢弃过滤通用词 sublinear_tfTrue # 对TF取对数抑制高频词权重 )), (clf, LogisticRegression( C1.0, # 正则强度越大越容易过拟合 max_iter1000, class_weightbalanced # 类别不均衡时自动加权 )) ]) pipeline.fit(train_texts, train_df[label]) preds pipeline.predict(test_texts) print(classification_report(test_df[label], preds, digits4))ngram_range(1, 2)在新闻标题上很重要因为“不涨”“未跌”“超预期”这类二元组合的情感极性跟单字完全不同。min_df3和max_df0.9是一对过滤参数前者去掉太稀有的噪声词后者去掉“的”“了”这种几乎每篇都有的词。sublinear_tfTrue让词频的影响变成对数增长避免某个词出现次数多就主导特征权重。class_weightbalanced在负面样本偏少时特别有用不加的话模型会倾向于预测多数类。跑完看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_len64): 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_lengthself.max_len, paddingmax_length, truncationTrue, return_tensorspt ) return { input_ids: encoding[input_ids].squeeze(), attention_mask: encoding[attention_mask].squeeze(), labels: torch.tensor(self.labels[idx], dtypetorch.long) } model_name hfl/chinese-roberta-wwm-ext tokenizer BertTokenizer.from_pretrained(model_name) model BertForSequenceClassification.from_pretrained(model_name, num_labels3) 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_epochs3, # 新闻标题数据量通常1-5万3轮足够 per_device_train_batch_size32, # 显存不够就降到16或8 per_device_eval_batch_size64, learning_rate2e-5, # BERT微调经典学习率别超过5e-5 warmup_ratio0.1, # 前10%步数做学习率预热 weight_decay0.01, evaluation_strategyepoch, save_strategyepoch, load_best_model_at_endTrue, metric_for_best_modelf1_macro ) trainer Trainer( modelmodel, argstraining_args, train_datasettrain_dataset, eval_datasettest_dataset, compute_metricslambda p: {f1_macro: f1_score(p.label_ids, p.predictions.argmax(-1), averagemacro)} ) trainer.train()max_len64对新闻标题足够大部分标题在 30 个字以内64 已经覆盖 99% 的样本设太大只会浪费显存。learning_rate2e-5是 BERT 微调的甜点值设到 5e-5 以上容易在训练后期震荡设到 1e-5 以下收敛太慢。warmup_ratio0.1让学习率从 0 线性升到目标值再衰减避免一开始就大步长破坏预训练权重。load_best_model_at_endTrue配合metric_for_best_modelf1_macro保证最终用的是验证集上最好的那个 checkpoint而不是最后一轮的。如果显存不够优先降batch_size其次降max_len最后才考虑换更小的模型。batch_size降到 8 还能跑的话用梯度累积gradient_accumulation_steps4模拟大 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, dtypetorch.float).to(cuda) # 自定义 Trainer 的损失函数 class WeightedTrainer(Trainer): def compute_loss(self, model, inputs, return_outputsFalse): labels inputs.pop(labels) outputs model(**inputs) logits outputs.logits loss_fn nn.CrossEntropyLoss(weightclass_weights) loss loss_fn(logits, labels) return (loss, outputs) if return_outputs else lossweights的计算逻辑是总样本数除以类别数再除以每个类别的样本数得到每个类别的权重。样本少的类别权重高损失函数对它的惩罚大。这个权重别设得太极端如果负面类权重是中性类的 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 条算 Cohens Kappa低于 0.7 就重新培训标注规则。模型侧可以尝试把三分类拆成两个二分类先判“有无情感”再判“正或负”中性类就是第一个分类器判为无情感的样本。4.4 现象训练 loss 正常下降但验证集 F1 波动很大每次跑结果不一样原因随机种子没固定。Python 的random、NumPy 的np.random、PyTorch 的torch.manual_seed、CUDA 的torch.cuda.manual_seed_all都要设。另外DataLoader的shuffleTrue如果不设generator每次 epoch 的顺序也不同。解决在代码最开头统一设种子并且把cudnn.deterministic设为True。注意设了deterministic之后训练会慢一些但结果可复现。如果验证集 F1 波动超过 3 个点还要检查是不是学习率太大导致在最优解附近震荡把learning_rate降一半再试。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) 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的paddingmax_length会把所有序列补到固定长度但如果attention_mask没正确传给模型模型会把 padding 的 0 也当成真实 token 处理。另一个常见原因是 batch 内样本顺序和返回结果顺序没对齐。解决批量推理时用paddingTrue让 tokenizer 动态补到当前 batch 的最长序列同时确保attention_mask传给模型。返回结果后用argmax拿到预测类别按输入顺序组装不要用多线程打乱顺序。如果用了DataLoader设shuffleFalse并记录每个样本的索引预测完按索引还原。def batch_predict(texts, model, tokenizer, batch_size32): model.eval() all_preds [] for i in range(0, len(texts), batch_size): batch texts[i:ibatch_size] encoding tokenizer( batch, paddingTrue, # 动态补到batch内最长 truncationTrue, max_length64, return_tensorspt ) with torch.no_grad(): outputs model(**encoding) preds outputs.logits.argmax(dim-1).cpu().numpy() all_preds.extend(preds.tolist()) return all_predspaddingTrue和paddingmax_length的区别在于前者按 batch 内最长序列补后者按固定长度补。推理阶段用paddingTrue更高效但要注意attention_mask必须传给模型否则 padding 位置会被当成真实 token。torch.no_grad()关掉梯度计算推理速度能快 30% 以上。5. 把模型用起来批量推理、置信度过滤与一个提升精度的小技巧模型训练完只是半成品真正产生价值是在推理阶段。新闻标题情感分析的推理有两个工程细节值得单独说批量处理和置信度过滤。批量处理前面已经给了代码核心就是paddingTrueattention_maskno_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, threshold0.65): model.eval() results [] for i in range(0, len(texts), 32): batch texts[i:i32] encoding tokenizer(batch, paddingTrue, truncationTrue, max_length64, return_tensorspt) 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 resultsF.softmax把 logits 转成概率分布max(dim-1)同时拿到最大概率和对应类别。阈值 0.65 是个经验值你可以根据业务对准确率和覆盖率的权衡来调阈值调高自动处理量减少但准确率上升阈值调低自动处理量增加但可能混入更多错误。建议在验证集上画一条阈值-准确率曲线找到准确率开始明显下降的拐点作为阈值。最后说一个提升精度的小技巧标题拼接。新闻标题往往不是孤立存在的它可能带副标题、栏目名、或者同一事件的前序标题。如果数据里有这些信息把它们拼接到主标题后面再送进模型能给模型更多上下文。拼接格式我一般用[主标题] [SEP] [副标题]BERT 能自然处理这种分隔。实测在财经和体育新闻上拼接副标题能带来 1 到 2 个点的 F1 提升。但注意别拼太多超过max_len截断反而丢信息。我自己做这类项目的习惯是先把 TF-IDF 基线跑通确认数据管道没问题再上 BERT 微调最后用置信度过滤和标题拼接做后处理。每一步都留一个可复现的脚本和固定的随机种子这样下次换数据或换模型时能快速定位是哪个环节出了问题。希望帮到你。本文还有配套的精品资源点击获取
返回列表