简介:面向毕业设计场景的微博评论情感分析项目包,适合机器学习与自然语言处理方向的学生及需要文本分类实践参考的开发者。内容围绕微博评论的正负面倾向识别,覆盖数据清洗、停用词处理、词袋与TF-IDF特征构建,以及支持向量机、朴素贝叶斯和AdaBoost三类模型的训练、交叉验证与准确率/召回率/F1评估,并附结果对比与优化思路,可作为毕业设计模板或素材使用。压缩包共70个文件、约6.05MB,以31个Python脚本承载预处理、训练与绘图流程,15个npy文件与4个model文件保存模型及中间数据,13个txt提供词表与语料,另含1份docx项目文档和说明文件,目录按算法与工具模块组织。已有382人学习下载,便于读者快速理解三种算法的实现差异、复用实验代码并整理成完整项目文档。
1. 需求切入:三个分类器摆在一起,才看得清短文本情感分析的难点
有人做微博评论文本情感分析,第一反应是直接上预训练模型,结果几千条标注数据喂进去,验证集准确率在 0.6 上下浮动,答辩时被问“为什么不用传统机器学习做基线”,当场卡住。真正能交差的方案,是先建立一条可复现的基线:用 SVM、朴素贝叶斯、AdaBoost 三个模型在同一份特征矩阵上跑同一套交叉验证,谁强谁弱、差在召回还是精确率,一张表说得清。这套方案解决的是从原始评论到可训练矩阵、再到三模型对比实验和完整文档的全流程,适合要交文本分类毕设的学生,也适合需要一份可改可交付的分类方案做参考的从业者。项目文档里的实验记录、参数表和混淆矩阵,才是答辩时最有说服力的部分。
2. 数据与特征工程:从原始评论到可训练矩阵的四道工序
情感分析项目失败,八成不是失败在模型上,而是失败在数据层。同一份语料,清洗规则换一版、停用词表换一份、TF-IDF 的参数动一动,F1 值能差出七八个点,而这些差异在论文里往往只有一句“对数据进行了预处理”带过,评审一问就露怯。所以这一章按工序拆开写:清洗、分词、特征表示、数据集划分,每一步都给规则和代码,读者可以照着改。
2.1 数据清洗:表情符号、@提及、话题标签分别怎么处理
短评论的噪声结构和长文本完全不同。一条典型的评论文本里会同时出现 URL、@用户名、#话题#、表情占位符、连续重复字符、纯数字编号,长度普遍在 20 到 60 字之间。清洗的原则是去掉与情感无关的噪声,保留携带情感信号的成分,而不是一把正则全部清空。
几个容易做错的地方值得单独说:
- 表情符不能直接删。中文社交平台的表情大多以方括号占位符形式出现,
[微笑]、[泪]、[赞]本身就是强情感标记。直接当符号删掉,等于主动丢掉了最干净的标注信号。通常的做法是映射成统一的“正面表情”“负面表情”两个 token,让模型能学到。 - 话题标签保留内容、去掉井号。
#某电影# 太难看了里的“某电影”是实体信息,对判断情感极性有微弱帮助,井号本身没有意义。 - @提及整段删除。用户名是专有名词,进入词表只会制造稀疏特征,还会让模型记住某些账号而不是学到情感表达。
- 长度过滤放在最后。清洗后少于 4 个字的样本直接丢弃,这类样本多为“转发”“哈哈哈”或无意义灌水,标注噪声极大。
清洗函数的实现如下:
import re # 常见表情占位符,实际项目中应补充到 50 个以上 EMOJI_POS = ["[微笑]", "[哈哈]", "[赞]", "[心]", "[鼓掌]", "[酷]"] EMOJI_NEG = ["[泪]", "[怒]", "[失望]", "[鄙视]", "[衰]", "[汗]"] def clean_text(text: str) -> str: text = re.sub(r"http\S+|www\.\S+", " ", text) # 去 URL text = re.sub(r"@[\w\u4e00-\u9fa5_-]+", " ", text) # 去 @ 提及 text = re.sub(r"#([^#]{1,30})#", r" \1 ", text) # 话题保留内容 text = re.sub(r"(\w)\1{2,}", r"\1\1", text) # 折叠"啊啊啊啊"为"啊啊" for e in EMOJI_POS: text = text.replace(e, " 正面表情 ") for e in EMOJI_NEG: text = text.replace(e, " 负面表情 ") text = re.sub(r"[^\u4e00-\u9fa5a-zA-Z\s]", " ", text) # 去剩余符号与数字 return re.sub(r"\s+", " ", text).strip()第 5 行的重复字符折叠是一条容易被忽略的规则:太好看了啊啊啊啊里的连续重复在分词阶段会产生大量单字碎片,折叠成两个字符既保留了强调语气,又控制了词表膨胀。第 11 行的正则把数字一并清掉,是因为价格、时间、楼层号这类数字在情感判断里几乎不提供信息,反而会在 TF-IDF 里形成高权重噪声特征。
清洗完还要做一层去重。转发内容、营销文案、复制粘贴的评论在同一话题下重复率很高,用文本哈希去重能砍掉 5% 到 15% 的样本。这一步不做,训练集和测试集之间会出现近似重复样本,交叉验证的分数会虚高,答辩现场被要求“随机抽一条测试样本看看”时就麻烦了。
标注体系建议先做二分类(正面 / 负面)。三分类加上中性类,类间边界极其模糊,“还行吧”“不算差”这类样本不同标注者的一致率很低,Cohen's Kappa 通常掉到 0.6 以下。先让两个人各标 500 条算一次一致性,Kappa 低于 0.75 就说明标注规范写得不够细,回去补规范比继续标更有价值。
2.2 中文分词与停用词:否定词必须进白名单
中文没有天然词边界,分词质量直接决定特征质量。jieba 的精确模式是这类项目的默认选择,全模式和搜索引擎模式会切出大量重叠词,让 TF-IDF 维度暴涨而信息量不增。
真正需要花时间的是两件事:加载领域词典和保护否定词。
领域词典要收网络新词、缩略表达、以及语料里高频但通用词典切不出来的组合词。做法是从训练集里跑一遍分词,统计词频,人工扫一遍 Top 2000 里的碎片词,把明显该成词却被切开的补进自定义词典。这个动作做一轮,特征维度通常能降 10% 左右,而有效信息不丢。
停用词表更危险。通用停用词表(常见的中文停用词清单)里包含“不”“没”“无”“非”“未”这类字,直接套用会把否定词全部删掉,不满意和满意在特征空间里变成同一个东西,模型学到的极性直接反转。这是情感分析里最隐蔽的一类错误,因为训练过程完全不报错,指标也正常,只是上限被锁死了。
import jieba jieba.load_userdict("dict/domain_words.txt") # 领域词、网络新词 STOPWORDS = set(open("dict/stopwords.txt", encoding="utf-8").read().split()) # 情感强相关词:否定词与程度副词一律保留 KEEP_WORDS = {"不", "没", "没有", "别", "无", "非", "未", "很", "太", "特别", "非常", "有点", "稍微", "极其"} def tokenize(text: str) -> list: words = jieba.lcut(text, cut_all=False) # 精确模式 out = [] for w in words: w = w.strip() if not w: continue if w in KEEP_WORDS: # 白名单优先于停用词表 out.append(w) elif w in STOPWORDS: continue elif len(w) == 1 and not w.isalpha(): continue else: out.append(w) return out这里KEEP_WORDS的判断必须放在停用词判断之前,顺序反了白名单就失效。程度副词保留的理由是它们能区分“难吃”和“非常难吃”,虽然二分类里两者极性相同,但在样本不平衡或需要输出置信度的场景下,程度信息会影响决策边界的稳定性。单字过滤那一行只过滤非字母单字,是因为“好”“差”“烂”这类单字本身就是强情感词,不能按长度一刀切。
2.3 特征表示:TF-IDF 的四个参数各自在管什么
文本分类毕设里,TF-IDF + 线性模型这个组合的性价比至今没有被取代。它的优势不在于精度上限,而在于训练快、可解释、调参路径清晰,三个模型能在同一份特征上公平对比。
TfidfVectorizer的参数不多,但每个都直接影响结果:
| 参数 | 建议取值 | 作用与踩坑点 |
|---|---|---|
ngram_range | (1, 2) | 二元组能捕捉“不开心”“没意思”“太贵了”,代价是维度翻数倍 |
min_df | 3 | 出现少于 3 条文档的词丢弃,压掉拼写错误和一次性噪声 |
max_df | 0.9 | 超过 90% 文档都有的词视为无区分度,注意别设太低误删情感词 |
sublinear_tf | True | 用 1+log(tf) 替代原始词频,抑制刷屏式重复词的主导作用 |
min_df和max_df是这套方案里最容易被低估的两个参数。语料只有几千条时,min_df=1会让词表膨胀到几万维,绝大多数维度只在一条样本里出现,模型等于在记样本;max_df设成 0.5 又可能把“喜欢”“垃圾”这类高频但强区分度的词砍掉。0.9 是一个比较安全的上界。
特征维度还需要再做一层筛选。卡方检验SelectKBest(chi2, k=5000)是文本分类里的常规做法,它按特征与类别的独立性打分,保留 top-k。k 的取值建议在 3000 到 8000 之间扫一遍,通常 5000 附近 F1 就进入平台期,再往上加只会拖慢训练。
from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.feature_selection import SelectKBest, chi2 vectorizer = TfidfVectorizer( tokenizer=tokenize, # 复用上面的分词函数 token_pattern=None, # 指定 tokenizer 时必须显式置空 lowercase=False, # 中文无大小写,关掉省开销 ngram_range=(1, 2), min_df=3, max_df=0.9, sublinear_tf=True, max_features=50000, # 先粗筛,再交给卡方精筛 ) selector = SelectKBest(chi2, k=5000)token_pattern=None这一行经常被漏掉:一旦传了自定义tokenizer却没关掉默认的 token 正则,sklearn 会按正则再切一遍,分词结果被二次破坏,而且不报错。这类“静默失效”在特征工程里特别多,写完一定要打印几行vectorizer.get_feature_names_out()亲眼确认词表长什么样。
2.4 数据集划分:分层抽样与固定随机种子
划分策略看着简单,实际上是实验可复现的地基。三个模型要对比,就必须保证它们看到完全相同的训练集和测试集,否则指标差异里混进了数据划分的随机性。
做法是先用train_test_split按 7:1.5:1.5 切出训练、验证、测试三份,stratify=y保证各份的类别比例一致,random_state固定成一个值并在文档里写死。后续所有模型的交叉验证只在训练集内部做,测试集在最终评估前一次都不能碰。
类别不平衡在这类语料里几乎是必然的,正面评论通常比负面多 20% 到 40%。分层抽样只保证比例一致,不解决不平衡本身,后面模型层的class_weight才是对策。但划分阶段要先把各类样本数打印出来记进实验日志,否则最后解释不了为什么召回率那么低。
3. 三模型落地:SVM、朴素贝叶斯与 AdaBoost 的参数与训练管线
三个模型放在一起不是为了凑数量,而是覆盖三种不同的归纳偏好:SVM 找最大间隔的决策边界,朴素贝叶斯基于条件独立假设做概率推断,AdaBoost 用一串弱分类器串行纠错。三者对同一份 TF-IDF 特征的反应差异很大,这种差异本身就是毕设里最值得写的一节实验分析。
统一用Pipeline把向量化、特征筛选、分类器串起来,好处是交叉验证时每一折的向量化只在训练折上拟合,天然规避特征泄漏。
3.1 SVM:线性核、类别权重与概率输出
短文本 + 高维稀疏特征这个组合下,线性核 SVM 通常是最稳的。RBF 核在小样本上偶尔能高出一两个点,但训练慢、参数敏感、可解释性差,毕设场景里性价比不高。
关键参数只有两个:C和class_weight。C控制对误分类的惩罚强度,文本分类里 0.5 到 2.0 之间扫一遍足够;class_weight="balanced"让少数类的样本权重按类别频率反比放大,这是应对不平衡最省事的做法,比上采样稳定。
需要输出概率时用CalibratedClassifierCV包一层。LinearSVC本身不提供predict_proba,而答辩演示里如果只输出“正面”两个字,说服力不如输出“正面,置信度 0.83”。
from sklearn.svm import LinearSVC from sklearn.calibration import CalibratedClassifierCV from sklearn.pipeline import Pipeline svm_pipe = Pipeline([ ("tfidf", vectorizer), ("chi2", selector), ("clf", CalibratedClassifierCV( LinearSVC(C=1.0, class_weight="balanced", max_iter=5000), cv=3, # 内部三折做概率校准 method="sigmoid", )), ])max_iter=5000是必须显式设的。默认 1000 在特征维度上万时经常不收敛,sklearn 会抛ConvergenceWarning,很多人直接忽略这条警告,结果模型停在一个次优解上,指标比实际能力低好几个点。看到这条警告就调大迭代次数或者降低C,别当没看见。
3.2 朴素贝叶斯:拉普拉斯平滑的取值逻辑
MultinomialNB在文本分类上的表现常被低估。它训练只要几百毫秒,在几千条样本的规模下准确率往往只比 SVM 低一到两个点,而且对特征维度不敏感,是一个非常好的基线参照物。
唯一的超参是平滑系数alpha。它的作用是把未见过的词-类别组合的概率从 0 拉到一个极小值,防止零概率导致整个后验为 0。alpha越大,平滑越强,模型越保守。文本分类里 0.1 到 1.0 是常规搜索区间,语料越小、词表越稀疏,alpha该往大调;样本上万、词表稳定时 0.1 到 0.3 通常更优。
一个使用前提:MultinomialNB要求特征非负。TF-IDF 的输出天然非负,可以直接喂;如果换成词向量均值或者做了标准化,就会报错或者结果异常,这一点在换特征方案时要留意。
from sklearn.naive_bayes import MultinomialNB nb_pipe = Pipeline([ ("tfidf", vectorizer), ("chi2", selector), ("clf", MultinomialNB(alpha=0.3, fit_prior=True)), ])fit_prior=True表示从训练数据估计类别先验,在不平衡语料上是合理的;如果做了人工的类别均衡处理,可以设成False让先验均匀,两种都试一遍再定。
3.3 AdaBoost:弱分类器深度决定成败
AdaBoost 在这套方案里承担的角色是“集成方法的对照”。它的机制是串行训练一系列弱分类器,每一轮提高上一轮分错的样本权重,最后按分类器准确率加权投票。
问题在于,文本的高维稀疏特征对树模型并不友好。决策树在稀疏矩阵上每次只能按单个维度切分,而 TF-IDF 里单个词维度的信息量很有限,所以基学习器必须保持很浅,否则单棵树就把训练集拟合完了, boosting 无从谈起。
基学习器用max_depth=1或2的决策树(决策 stump),树再深就直接过拟合。n_estimators和learning_rate是此消彼长的关系,学习率小就需要更多轮,常规组合是learning_rate=0.5配n_estimators=200,或者0.1配500。
from sklearn.ensemble import AdaBoostClassifier from sklearn.tree import DecisionTreeClassifier ada_pipe = Pipeline([ ("tfidf", vectorizer), ("chi2", selector), ("clf", AdaBoostClassifier( estimator=DecisionTreeClassifier(max_depth=2), # 弱到刚好比随机好 n_estimators=200, learning_rate=0.5, random_state=42, )), ])estimator这个参数名在较新版本的 sklearn 里替换了旧的base_estimator,如果环境里还是老版本,需要改回旧名,否则直接报TypeError。这种版本差异写进项目文档的“环境说明”一节,能省掉复现者半小时的排查。
实际跑下来,AdaBoost 在这类任务上通常比 SVM 低 3 到 8 个点,训练时间却是 SVM 的数倍。这个结果不是失败,反而是一个有价值的实验结论:高维稀疏的文本特征与基于特征切分的集成方法存在天然的不匹配。把这一点分析清楚,比硬调参把 AdaBoost 刷高一个点更有说服力。
3.4 统一实验管线:五折分层交叉验证与指标选择
三个模型的对比必须建立在同一套评估协议上。用StratifiedKFold做五折,每一折内部完成“拟合向量化器 → 卡方筛选 → 训练分类器 → 预测验证折”,Pipeline保证了这一顺序不会颠倒。
指标不能只看准确率。不平衡语料下,一个把所有样本都判为正面的模型准确率也能有 0.65,但它没有任何实用价值。评估指标至少要看四个:准确率、宏平均 F1、正面类 F1、AUC。宏平均 F1 对少数类更敏感,是这类任务里最该作为主指标的。
from sklearn.model_selection import StratifiedKFold, cross_validate scoring = { "accuracy": "accuracy", "f1_macro": "f1_macro", "f1_pos": "f1", # 默认取正类 "roc_auc": "roc_auc", } cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) for name, pipe in [("SVM", svm_pipe), ("NB", nb_pipe), ("AdaBoost", ada_pipe)]: scores = cross_validate(pipe, X_train, y_train, cv=cv, scoring=scoring, n_jobs=-1) print(name, {k: round(v.mean(), 4) for k, v in scores.items() if k.startswith("test_")})shuffle=True配固定random_state是必须的,否则每次跑出来的折划分不同,指标会有 ±0.02 的抖动,写进论文的数字就不可复现了。n_jobs=-1并行的是折而不是模型,五折并行在普通笔记本上几十秒就能跑完三个模型,这个开销完全可以接受。
超参搜索用GridSearchCV在训练集内部做,搜索空间别开太大。经验值是每个模型控制在 12 组以内的组合,否则搜索本身的时间会超过训练时间,而且在小样本上过度搜索很容易过拟合验证集。搜索完把最优参数和对应的分数一起记进实验日志,这是文档里“参数选择依据”一节的素材来源。
4. 避坑排查:情感分析项目里最容易出问题的五个环节
这一节列的五个问题,都是实际带项目时反复见到的,共同特点是不报错、指标看着还行、但模型的真实能力被严重限制。每一条按“现象 → 原因 → 解决”写,遇到对应症状直接对照排查。
4.1 特征泄漏:向量化器在全量数据上拟合
现象:交叉验证分数很高(0.90 以上),换成一份全新的测试数据,准确率直接掉到 0.7 附近。
原因:先对所有样本做了fit_transform得到 TF-IDF 矩阵,再划分训练测试集。这样 IDF 权重里包含了测试集的词频信息,min_df、max_df的统计也跨了训练测试边界,等于模型提前看到了测试数据的分布。这是文本分类里最常见的一种数据泄漏,因为代码顺序看起来完全正常。
解决:向量化必须放进Pipeline,让它在每一折内部只对训练折fit。如果确实需要预先算好矩阵(比如为了省时间),那就严格按“先划分、再只在训练集上fit、用同一个 vectorizer 去transform测试集”的顺序写,并在代码里加注释标注这个顺序不能改。
4.2 停用词误删否定词:极性判断整体反转
现象:模型对“不满意”“不好看”“没什么意思”这类样本几乎全部判错,而正面样本识别正常。混淆矩阵里负面类的召回率异常低。
原因:直接套用了通用中文停用词表,其中包含“不”“没”“无”“未”等单字,分词后这些否定词被全部过滤,不满意剩下的特征和满意完全一致。模型学到的是“满意”这个词对应正面,于是把所有带否定的句子都判成正类。
解决:建立否定词与程度副词白名单,在白名单判断中优先级高于停用词表;同时检查自定义词典里是否收了“不满意”“不好”“没必要”这类凝固否定词。改完之后对比一次混淆矩阵,负面类召回率的提升通常非常明显,这是最值得写进实验分析的一组对比数据。
4.3 类别不平衡:准确率虚高,少数类全军覆没
现象:准确率 0.85,看着不错,但负面类的 F1 只有 0.5,模型倾向于把所有拿不准的样本都判成正面。
原因:训练集里正面样本占 65% 以上,模型只要偏向多数类就能获得不低的准确率,梯度/损失函数被多数类主导。准确率这个指标在此时已经完全失去区分能力。
解决:三件事一起做。一是把主指标换成宏平均 F1,并在文档里说明为什么不以准确率为准;二是线性模型加class_weight="balanced",树模型用样本权重;三是在划分阶段就打印各类样本数并记入日志,让不平衡程度可见。如果负面样本确实太少,再考虑对少数类做随机过采样,但要注意只在训练折内部做,不能在划分前对整个数据集做。
4.4 分词词表缺失:新词被切碎成单字
现象:词表里出现大量单字特征(“绝”“子”“y”“d”“s”),模型对含网络用语的评论判断很不稳定。
原因:通用词典没有收录网络新词和缩略表达,yyds、绝绝子、emo这类词要么被切成单字,要么被切成字母碎片,情感信号被稀释成噪声维度。
解决:从训练集跑一遍分词统计词频,人工检查高频碎片词并补进domain_words.txt,然后重新训练。这个动作建议做两轮,第二轮重点看仍然残留的单字和字母组合。补完词典后特征维度会下降、单条样本的特征更“实”,模型收敛也更快。
4.5 AdaBoost 越训练越差:弱分类器太强或标签噪声过大
现象:n_estimators从 50 加到 200、500,验证集 F1 不升反降,训练集分数却一路走高。
原因:两种情况。一是基学习器深度设大了(比如max_depth=8),单棵树已经接近拟合训练集,boosting 的“纠错”机制失效,后续轮次只是在放大噪声。二是标注里有相当比例的错标样本,AdaBoost 的机制会不断加大错标样本的权重,模型被少数错误样本带偏,轮数越多偏得越狠。
解决:把基学习器深度压到 1 到 2,先跑一轮确认能收敛;然后抽查 100 条训练集里的高权重样本,看是否有明显错标,有的话先清理标签再训练。同时用staged_predict观察每一轮之后验证集分数的变化曲线,找到开始下降的位置,把n_estimators定在拐点之前,这比盲调参可靠得多。
5. 验收与答辩:用指标表和演示脚本证明模型不是碰巧跑通
验收这一步,很多人只准备一个“准确率 0.87”的数字,然后被追问“那负面评论能识别出多少”就答不上来。稳妥的做法是准备三层材料:一张三模型指标对比表、一张 SVM 的混淆矩阵、一段能现场跑的预测脚本。
指标表按下面的结构组织,测试集上的数字一次跑定,不要为了好看反复调参再重跑:
| 模型 | 准确率 | 宏平均 F1 | 正面 F1 | 负面 F1 | AUC | 单次训练耗时 |
|---|---|---|---|---|---|---|
| SVM(线性核) | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 |
| 朴素贝叶斯 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 |
| AdaBoost | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 |
表格里“单次训练耗时”这一列不要省。它能直接支撑一个答辩时的高频问题:既然 AdaBoost 效果不如 SVM,为什么还要做?答案就在耗时和 F1 的对比里——投入数倍训练时间换来更低的分数,这个结论本身就是实验价值。
演示脚本做成命令行交互形式,输入一句话,三个模型同时输出预测结果和置信度,现场效果比放静态截图好得多:
def predict_demo(text: str) -> None: cleaned = clean_text(text) for name, pipe in [("SVM", svm_pipe), ("NB", nb_pipe), ("AdaBoost", ada_pipe)]: proba = pipe.predict_proba([cleaned])[0] # 已做概率校准 label = "正面" if proba[1] >= 0.5 else "负面" print(f"{name:8s} -> {label} 置信度 {max(proba):.3f}")提前准备三类测试句:明确正面的、明确负面的、带否定或反讽的。第三类是用来展示模型边界的,主动说出“这类反讽样本三个模型都判错了,原因是训练集里反讽样本太少”,比等评审指出来强得多。
文档结构按“需求与数据 → 预处理 → 特征工程 → 三个模型的原理与参数 → 对比实验 → 结论与不足”组织,每一节的图表都要能在附录里找到对应的实验日志。答辩前把环境依赖版本(Python、sklearn、jieba)单独列一页,现场被要求复现时能立刻回答。
我早年交第一版方案时,只写了一句“准确率达到 0.86”,评审让我打开混淆矩阵,发现负面类召回只有 0.52,整个实验的说服力当场归零。从那以后我每次做分类项目,都强制自己先把混淆矩阵和宏平均 F1 打印出来再谈准确率,测试集只在最后跑一次,跑完的数字不管好坏都原样写进文档。希望这套结构能帮到你,把方案交得踏实一点。
本文还有配套的精品资源,点击获取