简介:这是一份基于朴素贝叶斯机器学习算法实现情感文本分析与分类的完整项目,内含可直接调用的源码与配套数据集。面向计算机相关专业学生及从业者,适用于期末课程设计、大作业等场景,解决从文本预处理、特征提取到情感分类的实践需求。压缩包共包含12个文件,文件组织清晰,以Python源码脚本、CSV格式的数据集文件及微博预训练词向量压缩包为主,辅以PyCharm工程与配置文件,整体大小约173.42MB。项目经过严格调试,评审得分达95分以上,可直接运行复现;使用者可基于完整的工程结构、清晰的数据流程,学习朴素贝叶斯原理、文本向量化方法及情感分类评估思路,从而独立完成一次完整的情感分类实验,也可快速搭建项目框架用于课程汇报或技能提升,目前已有1025人学习下载。
1. 一个朴素贝叶斯情感分类大作业,为什么值得当成正式项目做
如果你手里正压着一份“基于朴素贝叶斯机器学习算法实现情感文本分析与分类”的课程设计或毕业大作业,大概率已经搜索过一堆源码,却卡在“代码能跑,但不知道答辩时怎么讲”的尴尬位置。这篇文章想解决的,就是把这个经典题目从数据读取、分词、特征工程到模型评估讲成一条你真正能复现、能自圆其说的完整链路。
朴素贝叶斯在情感分析上的表现常被低估:它不需要大量标注数据,训练只要几秒,还能给出每个预测的概率依据,这些恰好都是大作业评分最看重的“完整性”。更重要的是,它的失败模式非常直观——你完全能通过错分样本反推到底是分词问题、停用词问题还是数据分布问题。这篇笔记适合所有需要交付一份可运行、可解释、略有亮点代码的人,我会把隐藏的坑也一并标记出来。
2. 先把贝叶斯讲透:这个算法为什么是情感分类的“省心选项”
2.1 从贝叶斯定理到“选概率最大的那个类”
朴素贝叶斯分类器的出发点非常简单:给定一条文本,我们想知道它属于“正向”还是“负向”。用数学语言说,就是计算后验概率 P(类别 | 文本),然后选择概率更大的那个类别。
贝叶斯定理给出了计算方法:
P(类别 | 文本) = P(文本 | 类别) × P(类别) / P(文本)
其中 P(类别) 是训练集中该类文本的占比,称为先验概率;P(文本 | 类别) 表示在该类别下这条文本出现的可能性。分母 P(文本) 对所有类别都一样,所以实际比较时可以不计算。问题在于 P(文本 | 类别) 很难直接估计——一条文本可能由几十个词组成,组合空间巨大。
这里就是“朴素”二字的由来:假设文本中每个词的出现概率相互独立。于是:
P(文本 | 类别) = P(词1 | 类别) × P(词2 | 类别) × … × P(词n | 类别)
这个假设显然不真实,“好吃”和“难吃”明显不独立,但它在工程上非常有效。对情感分类来说,词袋化的文本丢失了语序信息,而情感倾向主要靠情感词和程度词呈现,语序反而不是决定性因素。这也是我做这个题目时愿意选它当基线的核心原因:训练快、解释性强、对中小规模数据集很稳健。
2.2 拉普拉斯平滑与对数运算:两个让模型“能跑”的细节
理论看起来干净,但直接实现会遇到两个绕不开的实际问题。
第一个问题是零概率。如果某个词在训练集的“正向”类别中从未出现过,但在测试文本中出现了,P(词 | 正向) 等于 0,整条文本的正向概率就变成 0。经典的解决办法是拉普拉斯平滑:
P(词i | 类别c) = (count(词i, 类别c) + 1) / (总词数(类别c) + 词表大小)
分子加 1,分母加词表大小,保证所有词的概率大于零且总和为 1。这个操作对所有朴素贝叶斯实现都是标配,不加的话,模型遇到一个没见过的词就翻车。
第二个问题是数值下溢。一条文本有几十个词,每个词的概率通常远小于 1,连乘几十次后结果会小到超出浮点数精度范围,变成 0。解决办法是取对数:
log(P(文本 | 类别)) = Σ log(P(词i | 类别))
连乘变成连加,数值范围变得可控。你在任何成熟的 sklearn 或开源实现里看到 log 变换,原因就是这个。我自己第一次用原生 Python 写这个算法时,刚好在报告里分析过一次连乘到底变成 0 的过程,答辩时这个细节本身就成了加分项。
2.3 为什么情感分类不做深度学习,先试试朴素贝叶斯
很多同学看到“情感分类”第一反应是LSTM或BERT,但我一般建议大作业先想清楚数据量。课程设计的数据集通常是几千到几万条短文本,这个规模下深度学习模型学不到稳定的情感表达,反而容易过拟合到训练集的表达习惯上。朴素贝叶斯需要的标注数据少,训练不需要GPU,五分钟之内能跑完交叉验证,迭代试错成本非常低。
另一个理由是“可解释性”。评阅老师问“为什么这个样本被分成负向”时,朴素贝叶斯可以直接把每个词的概率贡献列出来,而深度学习模型是一个黑匣子,很难给出类似的解释。当然,如果作业明确要求对比深度学习方案,用朴素贝叶斯当基线再补一个强化模型是合理的思路,但没有人会在中小数据规模上一上来就放弃朴素贝叶斯这个稳定选项。
3. 把源码跑通的完整步骤:从数据集准备到评估报告
3.1 先看一下数据集长什么样:两种常见格式与加载脚本
解压后常见的数据集格式有两类。第一类是 CSV,包含文本列和标签列,标签通常是1/0或pos/neg;第二类是分好类的文本文件,一个目录一个类别。先写一个兼容两种格式的加载函数,方便直接适配手里的数据。
import pandas as pd import os def load_dataset(path, text_col='text', label_col='label'): """加载情感分类数据集,兼容CSV与目录结构两种格式""" if path.endswith('.csv'): df = pd.read_csv(path) # 统一列名,避免不同的原始列名干扰后续代码 df.columns = [text_col if c == df.columns[0] else label_col if c == df.columns[1] else c for c in df.columns] texts = df[text_col].astype(str).tolist() labels = df[label_col].tolist() return texts, labels # 目录结构:每个子目录名是标签,子目录内是文本文件 texts, labels = [], [] for label_name in os.listdir(path): label_dir = os.path.join(path, label_name) if not os.path.isdir(label_dir): continue for fname in os.listdir(label_dir): with open(os.path.join(label_dir, fname), 'r', encoding='utf-8', errors='ignore') as f: texts.append(f.read().strip()) labels.append(label_name) return texts, labels texts, labels = load_dataset('data.csv') print(len(texts), len(set(labels)))这段代码做了两件重要的事。一是把读进来的 DataFrame 列名统一,避免原始 CSV 列名是review、sentiment之类的变量名导致后面报错;二是对目录结构按标签名自动扫描,不依赖人工写死的映射表。errors='ignore'可以跳过个别文件里无法解码的字节,对脏数据比较稳妥。注意我在打印处统计了样本数和类别数,先看一眼类别数量是否平衡,这直接影响后面模型的行为。
3.2 中文分词的选型与 Jieba 的用法
中文文本不像英文有天然空格分词,必须先做分词。常见的做法是使用结巴分词,它能满足绝大多数场景。分词时需要注意两点:使用精确模式而不是全模式或搜索引擎模式,并且过滤停用词。
import jieba stopwords = set() with open('stopwords.txt', 'r', encoding='utf-8') as f: for line in f: stopwords.add(line.strip()) def tokenize(text): """返回过滤停用词后的分词列表""" words = jieba.lcut(text.strip()) return [w for w in words if w not in stopwords and w.strip()] sample_tokens = tokenize(texts[0]) print(sample_tokens[:20])lcut返回列表,比cut返回生成器更适合直接传给后续的特征构造。停用词过滤能大幅减少特征维度,也能去掉“的”“了”“还”这类对情感判断没帮助的词。但我要提醒一句:不要直接用网上随便下载的停用词表,先确认表里没有“不”“很”“太”“挺”这类否定词和程度副词——它们恰恰是情感分类中信号最强的词,如果被过滤掉,准确率会立刻下降几个点。这是一个很常见的坑,后面避坑章节会再展开。
3.3 特征工程:用词频还是 TF-IDF
分词之后要把文本转成数值向量,最常见的是两种选择:词频向量和 TF-IDF 向量。对于朴素贝叶斯,我倾向于使用 TF-IDF,因为它可以对常见词降权,对情感词和稀有词提权,有效减弱“的”“了”等高频无意义词对概率估计的干扰。
from sklearn.feature_extraction.text import TfidfVectorizer vectorizer = TfidfVectorizer( tokenizer=tokenize, ngram_range=(1, 2), min_df=2, max_features=20000 ) X = vectorizer.fit_transform(texts) print(X.shape)这里的参数值得逐个理解。tokenizer=tokenize把自定义分词函数接到向量器上;ngram_range=(1, 2)表示同时使用单个词和相邻两个词的组合,能捕捉“不好吃”这类局部否定表达;min_df=2过滤掉在数据集中出现次数少于 2 的词,减少噪声;max_features=20000限制特征维度,防止维数过高导致训练变慢。这三个参数是情感分类任务中调整空间最大的位置,后面会讲到再怎么调。
词频和 TF-IDF 怎么选,我总结出一条经验:数据量少于五千条、文本长度较短时,词频配合 ngram_range 也能得到不错的基线;数据量较大或者短文本里口语化程度高时,TF-IDF 会更稳。大作业里用 TF-IDF 更保险,答辩时还能解释“为什么这里不做纯词频”。
3.4 训练与评估:为什么只打印 accuracy 远远不够
模型训练本身只有几行代码,但评估部分需要做得比大多数作业更细。先划分数据集,再训练并输出完整评估报告。
from sklearn.model_selection import train_test_split from sklearn.naive_bayes import MultinomialNB from sklearn.metrics import classification_report, confusion_matrix X_train, X_test, y_train, y_test = train_test_split( X, labels, test_size=0.2, random_state=42, stratify=labels ) model = MultinomialNB(alpha=1.0) model.fit(X_train, y_train) y_pred = model.predict(X_test) print(classification_report(y_test, y_pred, target_names=['负向', '正向'])) print(confusion_matrix(y_test, y_pred))train_test_split里的stratify=labels是一个容易被忽略但非常重要的参数:它可以保证划分后训练集和测试集中正负样本比例与原数据一致。如果数据本身不平衡,而划分时又没做分层,测试集可能全部是负向样本,accuracy 虚高,答辩时一问就露馅。
MultinomialNB(alpha=1.0)的alpha就是前面提到的拉普拉斯平滑系数。默认值是 1.0,通常够用,但如果你发现模型对稀有词的权重过于敏感,可以把alpha调小到 0.5 或 0.1 再对比。评估部分只用 accuracy 是很多作业的通病,因为类别不平衡时准确率会骗人。classification_report里的 precision、recall、F1-score 才是评判模型好坏的关键,后面还会具体讲怎么读这份报告。
3.5 完整训练脚本:把上面几段串成可交付的代码
把前面的步骤组合成一个完整脚本,你可以直接跑通,再按自己的数据调整路径和参数。
import jieba import pandas as pd from sklearn.model_selection import train_test_split from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.naive_bayes import MultinomialNB from sklearn.metrics import classification_report, confusion_matrix # 1. 加载数据 df = pd.read_csv('data.csv') texts, labels = df['text'].astype(str).tolist(), df['label'].tolist() # 2. 加载停用词并定义分词函数 stopwords = set() with open('stopwords.txt', 'r', encoding='utf-8') as f: for line in f: stopwords.add(line.strip()) def tokenize(text): return [w for w in jieba.lcut(text.strip()) if w not in stopwords and w.strip()] # 3. 特征工程 vectorizer = TfidfVectorizer(tokenizer=tokenize, ngram_range=(1, 2), min_df=2, max_features=20000) X = vectorizer.fit_transform(texts) # 4. 划分与训练 X_train, X_test, y_train, y_test = train_test_split( X, labels, test_size=0.2, random_state=42, stratify=labels) model = MultinomialNB(alpha=1.0) model.fit(X_train, y_train) # 5. 评估与保存 y_pred = model.predict(X_test) print(classification_report(y_test, y_pred)) print(confusion_matrix(y_test, y_pred)) import joblib joblib.dump(model, 'nb_model.pkl') joblib.dump(vectorizer, 'tfidf_vectorizer.pkl')joblib保存模型与向量器是容易被漏掉的一步。大作业要求“可复现”时,演示环节只需要加载这两个文件就能跑预测,不用重新分词训练,演示更流畅。注意保存时模型和向量器要一起保存,因为它们是一对配套的,只存模型不存向量器,预测时就会报维度不匹配。
4. 大作业里的六个经典坑:踩中一个分数就往下走一档
4.1 加了停用词表后准确率不升反降
现象:去掉停用词之后,准确率比不加还低两三个百分点,预测结果对部分明显负向文本分类错误。
原因:停用词表里包含“不”“没”“别”“太”这类情感表达中承担否定和程度功能的词。比如“不太好吃”会被切成“太”“好吃”,模型只看“好吃”就判定为正向;“不推荐”被过滤后文本变成空序列,直接按先验概率分到多数类。
解决:检查停用词表,把否定词、程度副词和转折连词全部移除。最稳妥的做法是用自己的数据跑一遍,统计模型错分样本中被过滤词的比例,再针对性地调整。专业领域的情感分析通常需要定制停用词表,不要直接抄互联网上为搜索引擎设计的通用词表。
4.2 数据集类别不平衡导致模型“只会猜多数类”
现象:负向样本占 80%,正向样本占 20%。模型预测时把大部分样本都归为负向,accuracy 很高但正向类召回率几乎为零。
原因:朴素贝叶斯的先验概率直接来自训练集各类别占比,多数类先验概率大,后验概率天然偏向多数类。分类器没有能力自动纠正这种不平衡。
解决:首先用stratify=labels做分层划分,保证测试集分布与训练集一致。其次,评估时不看 accuracy,看 macro F1 或类别各自的 F1-score。如果数据本身差距过大,可以考虑对少数类做简单的上采样,或者给多数类加权。报告里说明类别分布并解释选择宏平均的原因,也是一个答辩时能讲的加分细节。
4.3 保存模型后预测报维度不匹配或词表错误
现象:训练时代码正常运行,重新加载模型后对新的文本做预测,报dimension mismatch或feature_names mismatch一类的错误。
原因:只保存了分类器模型,没有保存特征向量器。新文本进入向量化环节时,生成的特征列与训练时不匹配,模型无法对齐特征。常见做法是把model存成 pkl,却把vectorizer留在内存里,换环境或重启内核后就找不到了。
解决:把模型和向量器一起保存。推荐的做法是用字典整合再保存,或者用joblib.dump分别保存两个文件,并在报告里写明加载顺序:先加载 vectorizer 做 transform,再交给 model 做 predict。这样即使换一台机器,也能完整复现整个预测流程。
4.4 预测概率全是 0.0000,或者概率值小到无法解释
现象:调用model.predict_proba()查看每个类别的概率时,所有样本的输出都是 [1.0000, 0.0000] 这样的极端值,解释性大打折扣。
原因:MultinomialNB内部已经对概率做了对数变换,predict_proba返回的是由对数概率转换回来的指数概率。当文本较长、特征较多时,指数化后的概率差异会被放大,表面看就是“非常自信”。
解决:不要直接展示原始概率,改用model.predict_log_proba()查看对数概率,或者在报告里明确说明“预测概率经由对数空间计算后指数还原”。同样重要的是,不要在演示时把某个样本的 0.9997 概率拿出来作为论文级证据,朴素贝叶斯的概率校准本身并不精确。如果想校准概率,可以再拟合一个CalibratedClassifierCV,但这对大作业来说通常不必要。
4.5 测试集里出现表情符号、URL 和特殊字符,分词结果一团糟
现象:测试文本带有“https://xxx”“😂”“【转发】”等字符时,分词器会切出大量无意义的片段,特征匹配率下降,预测结果明显变差。
原因:朴素贝叶斯无法理解这些符号的情感语义,而分词器会把 URL 切成奇怪的词干,表情符号在词表里可能存在但频率极低,无法提供有效信号。
解决:在分词前做一次简单的文本清洗,把 URL、电话号码、连续数字和超长符号替换为特殊占位符,比如<URL>、<EMOJI>。也可以在向量化环节设置token_pattern只保留中文和英文词。清洗规则要放在分词函数之前执行,不要试图在分词后清洗,否则切开的片段无法拼回去。源码里建议保留一个clean_text函数,单独写单元测试级别的检查,防止清洗逻辑自身引入新问题。
4.6 train_test_split 不固定随机种子,分数忽高忽低没法复现
现象:每次运行脚本,准确率和 F1 分数都不一样,浮动达到 3%-5%,报告里写出来的数字显得不严谨。
原因:train_test_split默认使用全局随机状态,每次划分出的训练集和测试集不同,模型参数和评估指标也随之变化。如果不写死随机种子,别人复现你的实验时无法得到一致结果。
解决:所有有随机性的环节都固定random_state。不仅是划分数据,还包括向量化的采样逻辑和打乱顺序。建议在脚本开头设置统一的随机种子,并在报告里写明“固定 random_state=42 保证实验可复现”。这一步看起来简单,但评阅老师看到数字可复现,对作业严谨性的评价会明显提高。
5. 多走一步:用阈值和后验概率让你的作业从“能运行”到“能解释”
到这里代码已经能完整跑通,但如果你想让这份作业从“能运行”跨到“能解释”,还有最后一个环节值得做:利用模型的后验概率来辅助人工分析错分样本。具体做法是取出测试集中置信度最高和最低的若干样本,逐条查看模型为什么做出这样的判断。朴素贝叶斯的优势在于,每个词的概率贡献可以被拆解出来。
import numpy as np prob = model.predict_proba(X_test) conf = np.max(prob, axis=1) sorted_idx = np.argsort(conf) # 置信度最低的5条样本 low_conf_idx = sorted_idx[:5] for idx in low_conf_idx: print('预测:', model.classes_[np.argmax(prob[idx])]) print('真实:', y_test[idx]) print('置信度: %.3f' % conf[idx]) print('文本内容:', texts[idx])这段代码的输出文件可以直接放进作业附录,作为“误差分析”部分的支撑材料。当置信度低的样本被集中观察时,你会发现大多是含有反讽、转折或否定结构的句子。比如“这家店的服务态度真不错,就是上菜太慢”这类带转折的文本,词级模型很难正确处理。把这个观察写进报告,比单纯贴一张准确率表格更有说服力。
分类阈值也可以根据误差分析调整:如果高风险场景里更看重召回负向样本(比如舆情监控优先识别负向),可以把预测阈值从默认的 0.5 调高或调低。predict_proba给出的是各类概率,实际做判断的阈值在不平衡数据上是可以人为干预的。这个做法本质上是在精调决策边界,对朴素贝叶斯这种概率输出模型非常自然,也不破坏模型本身的可解释性。
另一个值得尝试的技巧是分类类别标签映射的调整。很多数据集里正向和负向的比值并不是 1:1,此时MultinomialNB(class_prior=[0.3, 0.7])可以手动指定先验,实验不同先验下 F1 的变化。这个参数可以让你直观理解贝叶斯先验如何影响最终判断,答辩时还能回答“为什么不直接修改训练集比例”这类问题。
我平时交这类作业前,一定会留出半天时间做错分样本筛查,把最典型的 10 个样本整理成表格,配上每个词的 log 概率贡献。这份材料几乎每次都能在展示环节派上用场,因为老师更想看到的是一个学生是否真正理解模型行为,而非模型指标高低。希望这份笔记能帮你在同样的方向上少走弯路。祝顺利。
本文还有配套的精品资源,点击获取