简介:面向新闻与微博评论场景的情感分析项目,将哈工大情感词典、知网情感词典与朴素贝叶斯、支持向量机、随机森林等机器学习方法相结合,适用于舆情监测、用户反馈挖掘等文本倾向性判断任务。压缩包共39个文件,包含16个Python脚本、9个Markdown说明文档、7个CSV数据文件以及若干备份文件,整体容量2.63MB。脚本覆盖情感词典关键词生成、新闻爬虫(含Spyder实现)与微博数据处理等环节,Markdown文档梳理了数据获取、研究背景与项目报告,CSV则用于模型训练与特征分析。现有33人学习下载,示例数据围绕“肺炎”等四阶段新闻标题展开,可帮助初学者理解情感词典构造、机器学习建模与中文文本预处理全流程。资料来自网络分享,仅适合学习交流,不可用于商业用途。
1. 评论情感分析不是二选一:词典和机器学习要放进同一条链路
做新闻和微博评论的情感分析,最常踩的坑是二选一:迷信词典规则的团队,几轮迭代后会发现微博新词永远追不完;直接上 BERT/LSTM 的团队,又经常在新闻评论的长句反讽上栽跟头,模型把“真是太好了”判成正向,而人眼一看就知道是反话。真正有效的路径不是拿词典去对抗模型,而是把词典当成模型的先验信号,在特征层、输入层甚至决策层做融合。这篇文章会沿着这条路来拆:先解决情感词典怎么为新闻与微博两个场景扩词,再给出三种能落地的融合实现,接着处理两个场景的预处理差异,最后聊如何用消融实验验证“词典增强”到底值不值得加。新手能照着代码跑通全流程,熟手能从这里边界条件、参数取舍和验证方法里找到有用的信息。
2. 情感词典从哪来:基础词表、领域扩充与 SO-PMI 取舍
2.1 别急着拿现成词典打分:新闻和微博的词分布差得很远
通用情感词典的问题在于覆盖率,更在于“词感”错位。新闻评论里大量出现“令人担忧”“不予置评”“漏洞百出”这类书面表达,而微博评论里高频的是“绝绝子”“yyds”“太emo了”。这两类词在同一个词典里的命中情况完全不同,直接用一份通用词表去跑线上数据,会出现两种典型偏差:新闻侧的无情感词被误判出情感,微博侧的网络用语根本匹配不到。
所以动手做融合之前,先得明确一个事实:情感词典不是下载一个包就能用的“成品”,它是需要结合语料做领域适配的“半成品”。你的标题里同时出现新闻和微博,就意味着词典要么做两份领域扩充,要么在构建时就把两类语料都喂进去,让词典本身先学会在两个场景里工作。
2.2 基础情感词典选型:通用资源与情感强度
常见做法是先选一套通用情感词典做底,再用领域语料往上补词。工业项目和学术项目里用得比较多的三类基础资源,各有侧重:
| 资源 | 覆盖特点 | 适用场景 | 主要局限 |
|---|---|---|---|
| 大连理工大学情感本体库 | 情感类别细,有强度划分 | 中文通用情感分析,适合做强度加权的底座 | 网络用语覆盖少,词表更新慢 |
| HowNet 情感分析词集 | 中英双语,正负分类明确 | 新闻这类书面语场景 | 缺少强度值,口语词少 |
| NTUSD 台湾大学情感词典 | 量级大,正负词对形式 | 扩充候选词,适合做后合并 | 用词偏书面和台式表达 |
如果只让我选一套做底,我会选带强度值的本体库。原因在后面融合时会体现:模型需要的是一个“痛恨=5,不满=2”这样的连续信号,而不是“痛恨=负,不满=负”这种被打平的二值标签。你把强度丢掉了,等于把词典里最值钱的信息留在了仓库里。
注意,这三类资源各有其组织形式,使用时建议统一转换成同一个 JSON 结构:
{ "词条": { "polarity": -1, "strength": 4, "pos": "verb", "source": "dut" } }保留词性是因为新闻评论里的“坑”作名词和作动词,情感含义并不相同;保留来源则方便出现冲突时追溯词条获取渠道。
2.3 用 SO-PMI 从评论语料里挖出词典里没有的极性词
基础词表搞定后,网感词的缺位依然存在。一般做法是用 SO-PMI(Semantic Orientation Pointwise Mutual Information)从领域语料里自动挖掘候选情感词,原理很简单:一个词如果和正向种子词高频共现,它就偏正向;和负向种子词高频共现,它就偏负向。
import math from collections import Counter, defaultdict import jieba def build_seed_words(): pos_seed = ["好", "赞", "喜欢", "推荐", "棒"] neg_seed = ["差", "烂", "坑", "垃圾", "失望"] return pos_seed, neg_seed def so_pmi(corpus, window=4, threshold=0.3): pos_seed, neg_seed = build_seed_words() co_occur = defaultdict(int) word_count = Counter() total = 0 for text in corpus: words = jieba.lcut(text) for i, w in enumerate(words): if len(w.strip()) < 2: continue word_count[w] += 1 total += 1 # 以当前词为中心,左右各 window 个位置为共现窗口 context = words[max(0, i - window): i + window + 1] for seed in context: if seed in pos_seed or seed in neg_seed: co_occur[(w, seed)] += 1 result = {} for (w, seed), cnt in co_occur.items(): if w in pos_seed or w in neg_seed: continue pmi_value = math.log2((cnt / total) / ((word_count[w] / total) * (co_occur[(w, seed)] and 1))) # 上面是简化近似,正式项目建议用文档频次替换词频 if seed in pos_seed: result[w] = result.get(w, 0) + pmi_value else: result[w] = result.get(w, 0) - pmi_value return {w: score for w, score in result.items() if abs(score) > threshold}这段代码里有两个参数值得细说。window控制共现半径,微博短文本建议设 3 到 4,新闻长句可以放宽到 5;窗口太大,噪音词会被拉进来,太小又捕捉不到搭配关系。threshold控制筛选强度,0.3 是一个比较宽松的值,适合先出一批候选词再过人工复核。注意这里为了演示做了简化,把种子词的共现频次近似当作词频使用,正式做的时候建议分别统计每个种子词的出现次数,再对正负得分分别求和,效果会稳定得多。
2.4 别把词典做成单值打分:输出结构要保留组合规则
词典扩充完毕后,下一步是定义“怎么用”。最容易踩的坑是每个句子只算一个情感总分,然后把它当特征扔给模型。这个总分确实有用,但它丢失了否定词、程度副词和转折关系的信息。比如“不是很满意”里的“不是”会翻转“满意”的极性,你不做窗口扫描,词典就会把这句话判成正向。
我一般会在词典打分函数里加一个否定词窗口,范围为当前情感词前 3 个词内。命中否定词就把情感强度取反,命中程度副词(“非常”“有点”“极其”)则把强度乘上一个系数。这一步产出的不是单一分数,而是一组特征:正负情感词计数、加权得分、经过否定翻转后的最终得分、程度副词数量。这组信号才是模型真正需要的输入,后面第 3 章的特征融合接的就是这个输出。
3. 把词典接入机器学习模型:三种能上线的融合实现
3.1 特征融合:TF-IDF 之外接上情感向量喂给分类器
这是最简单也最容易上线的做法,适合数据量不大、从零起步的场景。思路是把词典产生的结构化特征和常规文本特征拼在一起,喂给 LR、SVM 这类机器学习分类器。特征融合有两个直接好处:一是词典信号能起到规则先验的作用,在标注数据不足时给模型兜底;二是每个情感特征都有明确的系数,模型的可解释性还在。
import scipy.sparse as sp from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.linear_model import LogisticRegression from sklearn.model_selection import train_test_split from sklearn.metrics import f1_score def build_senti_features(texts, lexicon): features = [] for text in texts: words = jieba.lcut(text) pos_cnt, neg_cnt, score = 0, 0, 0.0 for i, w in enumerate(words): if w not in lexicon: continue item = lexicon[w] # 检查前3个词内是否有否定词 prefix = words[max(0, i - 3): i] sign = -1 if any(w in prefix for w in ["不", "没", "别", "无"]) else 1 score += sign * item["strength"] * item["polarity"] if item["polarity"] > 0: pos_cnt += 1 elif item["polarity"] < 0: neg_cnt += 1 features.append([score, pos_cnt, neg_cnt]) return sp.csr_matrix(features) X_tf = TfidfVectorizer(max_features=8000, ngram_range=(1, 2), sublinear_tf=True).fit_transform(X_train) X_senti = build_senti_features(X_train, lexicon) X_all = sp.hstack([X_tf, X_senti]) clf = LogisticRegression(C=1.0, class_weight="balanced", max_iter=1000) clf.fit(X_all, y_train)参数上,max_features设 8000 是因为评论语料里大量低频词对分类没有贡献,反而会让维度爆炸;sublinear_tf=True是对词频做对数缩放,压制高频词的主导地位;class_weight="balanced"用于处理情感标签不平衡,新闻评论里中立样本往往远多于正负样本。这三个参数在 LR 和线性 SVM 上表现都比较稳定,可以作为默认起点。
3.2 层融合:把情感先验拼进 LSTM 的输入层
如果数据量够大、文本长度较长,可以考虑用 LSTM 这类序列模型。李宏毅机器学习课里反复强调的“输入特征决定模型上限”,在情感分析任务里体现得很明显:LSTM 能学到上下文依赖,但它学不到词典里那种显式的情感强度。所以常见做法是把情感特征当作额外的输入通道,和词向量在时间步上拼接,让模型在每个位置都能看到规则信号。
import torch.nn as nn class SentiLSTM(nn.Module): def __init__(self, vocab_size, embed_dim, senti_dim, hidden_dim, num_layers=1): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0) self.senti_fc = nn.Linear(senti_dim, senti_dim) self.lstm = nn.LSTM( input_size=embed_dim + senti_dim, hidden_size=hidden_dim, num_layers=num_layers, batch_first=True ) self.classifier = nn.Linear(hidden_dim, 2) def forward(self, token_ids, senti_feat): emb = self.embedding(token_ids) # [B, T, embed_dim] senti = self.senti_fc(senti_feat) # [B, T, senti_dim] lstm_in = torch.cat([emb, senti], dim=-1) out, _ = self.lstm(lstm_in) return self.classifier(out[:, -1, :])senti_dim取决于你从词典里提取了几种信号,通常设 3 到 5,比如正负情感词计数、加权得分、否定翻转得分各占一维。senti_fc这个线性层不是必须的,但我一般会留着:它能让模型学习如何缩放和偏移词典信号,而不是让原始强度值直接主导梯度,这对 BiLSTM 的收敛更友好。拼接完成后,LSTM 的输入维度变成embed_dim + senti_dim,out[:, -1, :]拿的是最后一个时间步的隐状态,再接分类层输出情感类别。
3.3 集成交互:词典分值与模型概率的配合
第三层融合发生在模型之外,思路是把词典打分系统和机器学习模型当作两个独立的分类器,再做结果集成。这里不做复杂 Stacking,只做加权平均就够用:词典给出的是规则分数,模型给的是类别概率,两者尺度不同,需要先做归一化。
| 融合策略 | 数据需求 | 可解释性 | 主流适用场景 | 主要风险 |
|---|---|---|---|---|
| 特征拼接 | 几百条标注起步 | 高,LR 能看到特征系数 | 冷启动、快速上线 | 特征被稀疏 TF-IDF 淹没 |
| 输入层拼接 LSTM | 需要数千条以上 | 低,模型内部不可视 | 长文本、上下文依赖明显 | 词典噪音被模型放大 |
| 模型外加权集成 | 需要 Dev 集调权重 | 中,权重含义直观 | 团队已有两套独立系统 | 两套系统同源同错时无增益 |
这三种策略不是互斥的,线上系统完全可以在特征融合的基础上再做一次外层集成。权重怎么定,不靠拍脑袋,在 Dev 集上用网格搜索最小化误差即可。另外要留意:词典和模型若在同一份语料上训练,错误往往是相关的,集成收益会缩水;保持两套信号来源的异质性,比单纯调权重更有价值。
4. 新闻评论与微博评论的适配:同一个词典,两套管线
4.1 场景差异:长句反讽 vs 短句网络用语
把同一个融合模型直接从新闻评论迁移到微博,通常会出现性能滑坡。两个场景的文本属性差异太大:新闻评论平均长度在几十到上百字,具有较强的篇章结构,情感经常藏在转折和反讽里;微博评论短则几个字,多则一百多字,口语化程度高,表情符号和网络新词承担了大量情感信息。
这不是换个分词器就能解决的问题,而是预处理管线的整体差异。下面两节分别处理这两个场景的各自瓶颈,并给出对应的处理代码。
4.2 新闻评论的句子级聚合与转折处理
新闻评论的特点决定其更适合“先分句、后聚合”的策略。常见做法是先把整段评论按标点切分成句子,对每个句子单独计算情感得分,然后再加权聚合成篇章级分数。其中要特别处理转折关系——“但是”“然而”之后的句子通常承载了作者真实态度,权重应该适当提高。
import re def news_sentiment(text, lexicon): sents = re.split(r"[。!?!?;;]", text) total_score = 0.0 weight = 1.0 turn_words = ["但", "然而", "不过", "可是", "却"] for sent in sents: if any(w in sent[:3] for w in turn_words): weight = 1.5 sent_score = dict_score(sent, lexicon) # 词典打分函数 total_score += weight * sent_score return total_score / max(1, len(sents))weight的取值可以进一步学习,但 1.5 是一个现金经验值,在多数新闻评论语料上都能带来小幅提升。分句粒度带来的另一个好处是能观察到评论内部的“褒贬转折”,比如先肯定成绩再批评问题,这类评论如果不做分句,整体得分会被拉向中立,信息被损耗得很严重。
4.3 微博评论的 emoji 先验与网络用语兜底
微博评论的语料很短,分句反而会把上下文拆碎。更有效的方式是保留整句,同时把 emoji 当作额外的情感信号接入。严格来说这属于多模态情感分析的一部分,但实际落地时不需要训练视觉模型,做法是将高频 emoji 映射成情感先验分数,与词典得分做加和:
emoji_map = {"😂": 0.4, "😭": -0.5, "👍": 0.8, "👎": -0.8, "❤️": 0.6, "🤮": -0.7, "🥰": 0.7} def weibo_sentiment(text, lexicon): score = dict_score(text, lexicon) emoji_score = 0.0 for e, v in emoji_map.items(): if e in text: emoji_score += v return score + emoji_score需要说明的是,表情符号的极性在不同社区有时是相反的,比如“😂”在笑点低的语境里可能是正向,但在嘲讽语境里会带负面含义。建议先统计自己在手语料中 emoji 与文本情感标签的相关性,再决定映射方向和权重。
两个场景的管线差异,总结成一张对照表:
| 预处理维度 | 新闻评论 | 微博评论 |
|---|---|---|
| 切分粒度 | 按句切分,再做加权聚合 | 整句直接打分,不切分 |
| 否定词窗口 | 前后各 3 词 | 前 2 词即可,短句无需大窗口 |
| 转折词处理 | 需要,增强转折后句子权重 | 基本不需要,短句转折出现少 |
| emoji / 表情符号 | 可忽略 | 必须处理,做先验分数映射 |
| 词典扩充重点 | 书面词、领域词、反讽表达 | 网络新词、缩写、emoji 候选 |
| 词典中未命中的兜底 | 交给模型上下文特征 | 交给模型 + emoji 先验一起兜底 |
两套管线的差异,本质上由“信息载体位置”决定:新闻的情感信息分布在句子结构和修辞关系里,微博的情感信息集中在词汇本身和符号上。
5. 消融验证与词典增强的调优技巧
5.1 先搭一个消融实验框架,再谈调优
词典融合到底贡献多少,不能靠直觉判断,要跑对照实验。最基本的设置是四条线:纯词典规则、纯 TF-IDF + LR、特征融合(3.1)、输入层拼接 LSTM(3.2)。用同一个数据划分和统一评估脚本,在宏平均 F1 上对比差距。如果特征融合版比纯模型版低,说明词典信号和模型信号的方向存在冲突,第一步不是调参数,而是检查词典打分里极性标注是否正确。
下面是一个可直接套用的评估骨架:
from sklearn.model_selection import cross_val_score configs = { "dict_only": make_dict_rule_model(), "lr_tfidf": make_lr_tfidf(), "lr_fusion": make_lr_fusion(), "lstm_senti": make_lstm_senti(), } for name, model in configs.items(): scores = cross_val_score(model, X, y, cv=5, scoring="f1_macro") print(f"{name}: {scores.mean():.4f} (+/- {scores.std():.4f})")如果lr_fusion相比lr_tfidf的提升不足 0.5 个点,就要去查情感特征是否被 TF-IDF 维度稀释了,常见做法是对情感特征做缩放或提升其在拼接矩阵中的权重。
5.2 词典得分先做秩归一化,再入模型
直接拿词典的原始强度拼接进模型有个隐患:不同句子命中的情感词数差异很大,长句得分天然比短句高,这会让模型把“长度”当“情感强度”。解决办法是把得分转换成语料中的百分位秩,把绝对的强度差异变成“这句在语料中相对强弱”的相对差异。
5.3 集成时用 logit 变换而不是原始概率
在模型外做集成时,模型输出的概率集中在 0.2 到 0.8 之间,直接拿这个值和词典分数做加权,词典信号会被压缩得很小。先把概率变换到对数几率空间,再做加权,会让两类信号的可比性更强。
import numpy as np from scipy.special import logit p = np.clip(proba, 1e-7, 1 - 1e-7) # 防止 logit 溢出 logit_p = logit(p) final_score = 0.35 * rule_score_norm + 0.65 * logit_p权重取值 0.35 和 0.65 需要在 Dev 集上重新搜索。搜索时注意观察一个现象:如果最优权重无限偏向模型一侧,词典融合大概率只是“加了但没有完全加进去”,此时更应该回到 5.1 的消融实验,查特征构造环节,而不是继续调集成权重。
本文还有配套的精品资源,点击获取