简介基于朴素贝叶斯算法的豆瓣电影Top250评论情感分析系统源码及数据集面向具备机器学习基础的高校学生、毕业设计开发者及自然语言处理入门者。项目完整覆盖评论文本清洗、中文分词、特征提取、分类器构建与训练、情感倾向性预测的实践流程所有程序均通过系统性验证曾作为毕业设计课题获得98分评价可作为课程实践、综合课题设计或学位论文的参考素材。资源包共14个文件以Python源文件、Jupyter Notebook交互式分析文件、CSV评分数据、文本词典与停用词表、备份文件等为主压缩包大小3.28MB目录结构精简便于按模块阅读和二次开发。当前已有50人学习浏览。使用者可通过调整特征工程或分类器参数进一步拓展模型模块化设计让功能重组与算法改进更加便捷。1. 基于朴素贝叶斯的豆瓣Top250评论情感分析53行代码能跑通的最小闭环先把结论放在前面豆瓣电影Top250不是评分最高的250部电影它是按“评分人数与评分的加权计算”筛出来的一张综合榜单。把榜单里每部电影的短评抓下来做情感分析用朴素贝叶斯算法跑出一个分类系统是短文本分类里最经典、也是最容易落地的实战项目。标题里的“系统源码及数据集”翻译成大白话就是一条完整流水线采集评论、清洗打标、中文分词、训练模型、部署接口。我做这类项目时见过太多人一上来就上BERT和微调结果显存不够、标注样本不够卡在环境里出不来。豆瓣短评这个场景恰恰是朴素贝叶斯算法的主场数据量一万条上下、文本短且口语化、正负类可分性强普通笔记本一个下午就能跑完闭环。这篇笔记就按我实际做课设和内部工具的流程讲包含能直接抄的参数和坑。2. 评论数据集从哪来采集规则、清洗策略与标签怎么打才不翻车2.1 固定Top250而不去爬全站样本选择逻辑很多人在这一步就给自己挖坑一上来想爬豆瓣全站几百万条短评。先泼盆冷水做朴素贝叶斯不需要那么多数据一万条左右足够把模型训到可演示的水平数据规模再大只会把清洗时间拖长贝叶斯模型的收益很快进入平台期。常见的做法是把范围锁在Top250榜单内每部电影取热门短评的前50条左右凑出来的数据集既覆盖了不同类型的影片又不会让某几部大热片的评论在训练集里喧宾夺主。采集时用常规HTTP请求加一个HTML解析就能完成不需要模拟登录。豆瓣短评页面按 start 参数分页每页20条取前两到三页就够。下面这段示意代码只留了解析的主干真实运行时需要自己补请求头和处理网络异常。# collect.py 示意核心解析逻辑需要自行补充HEADERS和异常处理 import requests from bs4 import BeautifulSoup def fetch_comments(douban_id: int, limit: int 50) - list: comments [] for start in range(0, limit, 20): url fhttps://movie.douban.com/subject/{douban_id}/comments?start{start} resp requests.get(url, headersHEADERS, timeout10) soup BeautifulSoup(resp.text, html.parser) for item in soup.select(.comment-item): short item.select_one(.short) if short: comments.append(short.get_text(stripTrue)) return comments这段代码里 limit 控制每部电影最多取多少条start 是翻页偏移量两者配合能限制整体数据量。重点在于收紧 limit不要让脚本无限翻页——一方面训练不需要那么多数据另一方面高频请求容易被限流。我在真实项目里会让每两次请求之间随机等 0.5 到 2 秒并且只爬一层页面不点进“全部评论”去翻几十页。还要遵守目标网站的使用条款爬下来的数据仅用于学习和研究。那数据集文件长什么样按我习惯的格式清洗后保存成 CSV包含四列movie电影名、rating用户打分、text短评原文、label情感标签1为正、0为负。如果读者拿到的是网上流传的 douban_top250_clean.csv 之类的现成数据集检查一下列名基本都能对上。这里有个容易被忽略的小事原始评论里会混着很多短评附带的标签文本比如“来自手机客户端”“剧透预警”采下来之后要一并清掉。2.2 清洗与弱标注先用情感词典打一版标签再人工校验清洗规则直接决定后面特征工程的质量。豆瓣短评里高频出现的噪声是转义字符比如 、URL、纯表情的重复文案还有“好看好看好看”这种同一句话被豆瓣去重后仍留下的极端重复。我一般会按这个顺序处理先解码HTML实体再去URL和话题标签然后把空白字符压缩最后按长度过滤掉少于4个字的短评。少于4个字意味着分词后只剩一两个词模型几乎学不到有效特征。比较棘手的是打标签。很多教程直接按星级切分4星以上算好评2星以下算差评。这种做法第一版能跑出很高的准确率但部署到真实场景容易翻车原因后面避坑章节细说。更稳的做法是用情感词典做弱标注准备一张正面词表经典、惊艳、温暖、震撼和一张负面词表烂片、尴尬、拖沓、失望统计命中次数后决定初始标签。无法判定的样本标记为 -1留给人工抽查修正。# label_utils.py 弱标注逻辑 def mark_label(text: str, pos_words, neg_words) - int: pos_hits sum(1 for w in pos_words if w in text) neg_hits sum(1 for w in neg_words if w in text) if pos_hits neg_hits: return 1 # 正面 if neg_hits pos_hits: return 0 # 负面 return -1 # 待人工处理这段逻辑的关键是“谁命中的词多听谁的”而不是“命中任意词就定标签”——否则“没那么烂”会被 neg_words 里的“烂”直接标记成负面这个错误会污染整个训练集。标记为 -1 的样本不应直接丢弃而是抽出来人工看一眼。我实际处理时大概会有 15% 到 20% 的短评落在 -1 区间人工看两百条足够修正偏差。这步做完就得到一份可以进入训练流程的干净数据。3. 中文分词与向量化jieba与TF-IDF把短评变成稀疏矩阵3.1 jieba参数与停用词为什么“的”“了”留在特征里会稀释信号朴素贝叶斯处理文本时输入的不是原始句子而是词频统计。中文和英文不一样词与词之间没有天然空格所以先要分词。jieba 是这里最常用的工具没有之一。分词模式上我用精确模式也就是 jieba.lcut(text)它适合做情感分类比全模式少产生无意义的冗余词也比搜索引擎模式更稳。分词之后必须做两件事过滤停用词、过滤单字词。停用词指的是“的、了、吗、啊、就”这类语法词它们几乎均匀出现在正负样本里对分类没有任何区分度但会占用特征维度。单字词“好”“烂”单独出现时语义不稳定在TfidfVectorizer里还会制造大量低频特征我一般宁可去掉让模型聚焦在两字词和三字词上。import re import jieba stopwords set() with open(data/stopwords.txt, encodingutf-8) as f: stopwords {line.strip() for line in f if line.strip()} def preprocess(text: str) - str: text re.sub(r\s, , text.strip()) words jieba.lcut(text) # 过滤停用词和单字词再拼回空格分隔的字符串 kept [w for w in words if w not in stopwords and len(w) 2] return .join(kept)注意 preprocess 返回的是字符串不是词列表。这是因为后面接 sklearn 的 TfidfVectorizer 时它默认按空格切分已经分好词的文本不需要再做一次分词。如果你把词列表直接传给 fit_transformTfidfVectorizer 会把每个词当成一个文档结果完全错乱。这个细节我见过不少人在第一次实验时踩进去。分词阶段还有一个容易被忽略的参数词典。豆瓣短评里会出现电影名、导演名、演员名例如“流浪地球”“诺兰”。如果默认词典没收录就会被切成“流浪”和“地球”语义就散了。常见做法是往 data/userdict.txt 里加专名然后调用 jieba.load_userdict 加载。不要迷信默认词典尤其是处理垂直领域文本时自定义词典是必须的。3.2 向量化与TF-IDF的选择频次、稀有度和特征上下限分词之后文本还不能直接喂朴素贝叶斯。模型拿到的是特征矩阵每一行代表一条短评每一列代表一个词矩阵里的值是这个词在当前文本里的TF-IDF权重或词频。这个转换在 sklearn 里叫 TfidfVectorizer。那到底用词袋 CountVectorizer 还是 TF-IDF两者都能和朴素贝叶斯搭配区别在于CountVectorizer 保留原始词频适合情感词出现频率本身就是信号的场景TF-IDF 会放大稀有词的权重、压低“电影”“剧情”这类普遍出现的高频词让分类更关注局部判别信号。豆瓣短评这种文本里高频无意义词太多我用 TF-IDF 的效果普遍好出 2 到 4 个点。下面是完整向量化代码。from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.model_selection import train_test_split vectorizer TfidfVectorizer( ngram_range(1, 1), # 只保留单个词避免维度过高 min_df2, # 至少在2条短评里出现过的词才保留 max_df0.8, # 超过80%文本都含有的词视为噪声剔除 sublinear_tfTrue, # 用1log(tf)压平词频差异 token_patternr\b[\u4e00-\u9fa5A-Za-z]\b, ) X vectorizer.fit_transform(df[text_cut]) y df[label] X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, random_state42, stratifyy )这里逐个参数说明。min_df2 表示一个词至少在 2 条短评里出现才进入特征表这能把只出现在一条评论里的无名词汇过滤掉。max_df0.8 的作用是剔除“电影”“一部”“觉得”这类几乎每条评论都有的词它们对分类没有帮助。sublinear_tfTrue 的意义在于压平长评论的词频优势一条 200 字的影评出现 6 次“好看”和一条 20 字短评出现 3 次“好看”用原始词频会严重偏向长文本取对数后差距缩小更公平。这些参数不是拍脑袋定的我在小数据集上反复调过min_df2、max_df0.8 是最不容易过拟合的起步组合。4. 朴素贝叶斯训练与调参多项式模型、alpha平滑与效果评估4.1 MultinomialNB关键参数平滑系数和先验概率朴素贝叶斯的原理不复杂根据贝叶斯定理计算“给定一组词时文本属于正面或负面”的后验概率再假设词与词之间相互独立把每个词的条件概率相乘。这个独立性假设在真实语言里显然不成立但文本分类任务上它仍然表现稳定尤其适合维度高、样本量不大、类别边界的任务。sklearn 里对应多项分布场景的是 MultinomialNB它适合整数词频配合 TF-IDF 的浮点权重也能工作而且比 GaussianNB 稳定得多。from sklearn.naive_bayes import MultinomialNB from sklearn.model_selection import GridSearchCV param_grid {alpha: [0.01, 0.1, 0.5, 1.0, 2.0]} gs GridSearchCV( MultinomialNB(fit_priorTrue), param_grid, cv5, scoringf1, n_jobs-1, ) gs.fit(X_train, y_train) model gs.best_estimator_ print(best alpha:, gs.best_params_)alpha 是拉普拉斯平滑系数它解决的是“某个词只在训练集正面样本里出现过测试集遇到它时条件概率变零”的问题。alpha 越大所有词的计数都加上一个先验值概率分布被抹得更平模型越保守alpha 越小模型越相信训练集里的词频差异容易过拟合。我在这类几千到两万条的数据集上调参alpha 通常落在 0.1 到 1.0 之间0.5 附近表现最稳。GridSearchCV 里我用了 f1 作为打分标准而不是 accuracy是因为短评数据里正负样本往往不完全均衡accuracy 会被样本量大的那个类别带跑偏。fit_priorTrue 表示让模型从训练集里估计先验概率也就是正面评论占比多少、负面占多少。豆瓣短评整体偏正面保留先验能更贴合真实分布。如果数据集经过人工平衡先验比例可能失真这时再考虑 fit_priorFalse。class_prior 手工指定先验一般用不上除非你对线上分布有更精准的把握。4.2 评估与阈值只看准确率会被样本分布欺骗模型训练完不能只看 score() 输出的 accuracy。要打印完整的分类报告和混淆矩阵看清正面类被分错的多还是负面类被分错的多。如果任务是“找出差评”那么召回率比精确率更重要如果是做综合评分F1 更均衡。from sklearn.metrics import classification_report, confusion_matrix y_pred model.predict(X_test) print(classification_report(y_test, y_pred, target_names[负面, 正面])) print(confusion_matrix(y_test, y_pred))实际效果大致长这样不同数据集会有波动负面类精确率 0.85 左右召回率 0.74 左右正面类精确率 0.81召回率 0.89。你会发现正面类明显更容易被分对原因是正面情感词表更集中“好看”“经典”“喜欢”这类词的判别力更强而负面表达花样多反讽和吐槽往往不带典型负向词。还有一个术不要只用 0.5 作为正负类分界。MultinomialNB 的 predict_proba 会输出两个类别的概率把概率阈值从 0.5 调成 0.6意味着只有当模型有 60% 以上信心时才判为正面否则归为负面。这能牺牲一部分正面的召回率、换回更高的精确率。我用验证集做过尝试效果如下表正面阈值负面精确率负面召回率F10.50.850.740.790.60.890.680.770.40.810.810.81阈值调到 0.4 时负面类 F1 反而更高因为模型更敢于把边界样本判为负面。具体调多少要根据产品诉求定我的习惯是在训练阶段先跑一遍 0.3 到 0.7 的阈值扫描选出 F1 最高点再代入后续系统。5. 系统源码落地训练脚本、预测模块与Web接口怎么组织5.1 源码目录一页文件布局换项目不迷路模型只是一个 jupyter notebook 里的变量系统才是能给别人用的东西。我一般把代码组织成如下结构既适合课程设计也适合内部小工具快速迭代。sentiment_system/ ├── data/ │ ├── douban_top250_raw.csv # 原始采集数据 │ ├── douban_top250_clean.csv # 清洗并打好标签 │ └── stopwords.txt ├── models/ │ ├── nb_model.pkl # 训练好的模型 │ └── tfidf_vectorizer.pkl # 向量化器 ├── src/ │ ├── preprocess.py # 清洗与分词 │ ├── train.py # 训练与评估 │ └── predict.py # 加载模型做预测 └── app.py # Flask Web 接口这个目录最重要的设计是把“训练时用的状态”和“预测时用的状态”分开。predict.py 不接触训练代码只加载 models 目录下两个 pkl 文件。很多初学者会把 vectorizer 和 model 混在一起存预测时又去重新 fit_transform导致线上结果和训练结果对不上。训练脚本的收尾动作是把模型和向量化器持久化sklearn 官方推荐用 joblib。# src/train.py 末尾 import joblib joblib.dump(model, models/nb_model.pkl) joblib.dump(vectorizer, models/tfidf_vectorizer.pkl) print(model saved)保存时注意把 vectorizer 也存下来而且必须和模型是同一份。预测时先 transform 再 predict顺序反了会报维度不匹配。5.2 从训练到预测的完整链路joblib 加载模型与批量接口预测模块的核心代码很短但每个环节都有讲究。# src/predict.py import joblib import jieba from src.preprocess import preprocess, stopwords model joblib.load(models/nb_model.pkl) vectorizer joblib.load(models/tfidf_vectorizer.pkl) def predict_sentiment(text: str) - dict: clean preprocess(text) x vectorizer.transform([clean]) proba model.predict_proba(x)[0] label int(model.predict(x)[0]) return { label: label, negative_prob: round(float(proba[0]), 4), positive_prob: round(float(proba[1]), 4), }这里必须用 transform 而不是 fit_transformvectorizer 已经在训练时拟合过特征表预测阶段只能映射不能重新学习。predict_proba 返回的是一个二维数组取 [0] 拿到当前样本的一个概率分布。返回 negative_prob 和 positive_prob 而不是只给标签能让调用方自己决定阈值而不是把判断卡死在 0.5。Web 接口我用 Flask 起一个轻量服务只暴露一个 POST 接口接受 JSON 里的 text 字段返回情感标签和概率。Flask 在本地系统里足够轻如果用 FastAPI 也可以逻辑完全一样。# app.py from flask import Flask, request, jsonify from src.predict import predict_sentiment app Flask(__name__) app.route(/sentiment, methods[POST]) def sentiment(): data request.get_json(forceTrue) text data.get(text, ) if not text.strip(): return jsonify({error: empty text}), 400 return jsonify(predict_sentiment(text)) if __name__ __main__: app.run(host0.0.0.0, port8000, debugFalse)启动后建议先做一次自测curl -X POST http://127.0.0.1:8000/sentiment -H Content-Type: application/json -d {text: 太惊艳了看完久久不能平静}应该返回负面概率很低、正面概率很高。这里有一点要提醒debugTrue 千万别在生产环境开它会暴露交互式调试器这个错误我早期犯过一次后来长记性了。6. 避坑与进阶4条踩坑记录和后续优化方向6.1 把评分当标签测试F1虚高却在真实数据上翻车现象按 4 星以上判正面、2 星以下判负面训练集和测试集的 F1 都能到 0.9把模型拿到未标注的真实短评上预测效果明显变差。 原因豆瓣短评里“.quote”式反讽大量存在比如用户给 2 星却写“这么烂的片还能拍出来真不容易”按评分切标签时这类文本的语义和标签方向是拧着的。模型学到的是“评分的旁门左道”不是真实情感。 解决换成情感词典弱标注加人工校验宁可让训练集小一点也要保证标签代表真实的语言情感而不是平台评星。6.2 过短短评分词后只剩一两个词向量稀疏得离谱现象一部分短评只有“好看”“难看”四个字清洗后更短TF-IDF 矩阵里这一行几乎全是零。 原因豆瓣短评本来就有大量短文本停用词过滤加去单字词之后有效特征不足。 解决过滤时提高最短长度门槛比如清洗阶段滤掉少于 4 个字的评论也可以把同一条电影下短评按去重逻辑合并但这招要谨慎合并多了会引入数据泄漏。更稳的做法是直接丢弃过短样本。6.3 随机切分导致同一部电影的评论同时出现在训练集和测试集现象交叉验证时 F1 很高部署后始终对不上。 原因同一部电影的高频词片名、演员名高度集中随机切分让这些词同时出现在两端模型等于提前记住了答案。 解决按电影名分组切分确保同一电影的短评只在训练集或测试集其中一边用 sklearn 的 GroupShuffleSplit。from sklearn.model_selection import GroupShuffleSplit gss GroupShuffleSplit(n_splits1, test_size0.2, random_state42) train_idx, test_idx next(gss.split(X, y, groupsdf[movie])) X_train, X_test X[train_idx], X[test_idx] y_train, y_test y[train_idx], y[test_idx]6.4 CSV读写中文变乱码现象read_csv 之后中文变成 \uFFFD 或满屏方框。 原因文件实际编码是 GBK却按 UTF-8 读取或者保存时没指定编码。 解决读写都显式指定 encodingutf-8如果需要用 Excel 打开改成 utf-8-sig 写入。df.to_csv(data/clean.csv, indexFalse, encodingutf-8-sig)6.5 后续优化方向这套朴素贝叶斯方案跑通之后提升空间主要在特征层面。先把 ngram_range 从 (1,1) 改成 (1,2)让“不好”“没意思”“太惊艳”这类二元组合进入特征否定词问题能缓解一半。再用一张情感词典给命中的词加权在 transform 后手动把矩阵里对应词位的值调高。这两步做完在我自己的评论数据集上 F1 又涨了 3 到 5 个点。如果之后要支撑线上系统不要只看离线指标一定要留一批按时间顺序排列的最新短评做滚动验证看模型在“新词”“新片名”上的衰减速度。说句实在话我最早用朴素贝叶斯做豆瓣短评时被评分标签那个坑折腾了一周来回调特征、换模型都无济于事最后发现是标签本身错了。后来我把弱标注和人工校验当成固定流程准确率才真正稳定下来。如果你准备拿这个方向做自己的项目建议也从数据采集和打标环节开始认真对待这套流程换到电商评论、应用商店评价里同样能复用。希望帮到你。本文还有配套的精品资源点击获取