豆瓣影评情感分析实战:从数据清洗到朴素贝叶斯调参 简介这是一套面向自然语言处理与情感分析入门者的实践型项目包基于朴素贝叶斯算法实现豆瓣影评情感分类覆盖从Scrapy爬虫抓取约5万条影评、好评差评各半的数据集构建到sklearn建模训练与评估的完整流程。资源共8个文件以4个Python脚本为核心分别承担训练、预测、测试与情感分析封装辅以停用词表、用户词典等文本配置、标注语料csv及可交互的ipynb笔记本压缩包大小仅3.26MB适合快速复现和二次实验。按4:1划分训练集与测试集当前准确率约80%实验还额外分析了“好评中带负面词”对准确率的干扰可引导读者进一步优化模型。已有3371人浏览学习适合作为课程设计、毕业设计或入门实战参考。1. 情感分析项目的真实起点不懂豆瓣数据先别急着写贝叶斯拿到“基于朴素贝叶斯实现的豆瓣影评情感分析”这个题目时很多人第一反应是赶紧import sklearn把朴素贝叶斯跑起来再说。我见过不少人工智能大作业和课程项目最后翻车都翻在同一个地方数据还没看明白模型就先写完了。豆瓣影评的情感分析核心不是贝叶斯公式有多深而是你能不能让模型真正“看懂”中文短文本里的情绪倾向。这篇笔记我按自己做项目的顺序来写先讲怎么把豆瓣短评变成干净的CSV再讲朴素贝叶斯为什么适合这种中文短文本场景之后落到特征工程、训练调参和验证最后把最容易翻车的四个坑单独拉出来说。适合正在做NLP课程作业、人工智能项目实践或想入门文本分类的人目标是让你照着做就能复现一个可解释、可验证的情感分析模型。2. 拿到数据抓取豆瓣短评的两条路线与字段清洗2.1 路线一从公开页面抓短评先解决请求头和解析节点豆瓣影评数据没有官方开放接口最直接的办法是自己写一个轻量抓取脚本把某个电影页面的短评列表拉下来。这里说的是公开页面上的静态内容只做少量抓取用于学习大批量采集前务必先看网站的robots条款并控制请求频率。我一般用requests加BeautifulSoup完成第一步解析。要注意豆瓣对请求头里的User-Agent很敏感不带浏览器标识的请求很容易被拒绝另外短评列表是分页的每页大约20条页码通过start参数控制。import time import requests from bs4 import BeautifulSoup headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36 } def fetch_douban_comments(movie_url, pages5): comments [] for page in range(pages): url f{movie_url}comments?start{page * 20}limit20statusP resp requests.get(url, headersheaders, timeout10) if resp.status_code ! 200: print(请求失败状态码, resp.status_code) break soup BeautifulSoup(resp.text, html.parser) for item in soup.select(.comment-item): comment_text item.select_one(.short) rating item.select_one(.rating) if comment_text: comments.append({ text: comment_text.get_text(stripTrue), rating: rating.get(title, ) if rating else }) time.sleep(1) # 控制频率别给服务器添麻烦 return comments这段代码的逻辑很直接先按分页拼出URL再用.comment-item选中每条短评的容器最后分别取出评论文本和星级。参数说明pages控制抓多少页time.sleep(1)是必须的用来降低请求频率statusP是短评列表常见的查询参数如果没有可以去掉。如果请求状态码一直不是200优先检查User-Agent是否完整或者手动在浏览器里打开目标页面确认URL结构。2.2 路线二用现成影评数据集把精力留给建模爬虫不是这个项目的重点。如果你只是想把情感分析模型跑通可以直接找整理好的豆瓣影评CSV网上有不少课程项目公开过清洗后的语料。我建议优先选带评分字段的数据因为评分是构造标签的唯一可靠依据。拿到原始CSV后第一步是检查列名和缺失值情况。常见的数据格式是movie_name, comment, rating, comment_time四列但不同来源列名可能不一样读进来之后先用info()和head()看一眼。import pandas as pd df pd.read_csv(douban_comments.csv, encodingutf-8) print(df.info()) print(df.head()) # 去掉完全没有评论文本的行 df df.dropna(subset[comment]) # 如果评分是 力荐 推荐 这种文字先映射成数字 rating_map { 很差: 1, 较差: 2, 还行: 3, 推荐: 4, 力荐: 5, 很差 : 1, 较差 : 2, 还行 : 3, 推荐 : 4, 力荐 : 5 } if df[rating].dtype object: df[rating] df[rating].map(rating_map)这里有一个容易忽略的细节字符串类型的评分列里可能带着看不见的全角空格所以映射字典里我故意加了几种带空格的情况。你也可以用df[rating] df[rating].str.strip()先清洗再映射。dropna(subset[comment])只删除评论为空的行评分缺失的行先保留等构造完标签再统一处理。2.3 清洗字段去HTML、去重、按评分映射情感标签影评数据里常混入HTML标签、多余空白、URL和重复内容。清洗规则我一般按顺序做先去掉HTML标签再压缩空白字符最后按评论文本去重。注意去重要连同评分一起判断否则同一条评论出现在不同电影下会被误删。import re def clean_text(text): text re.sub(r.*?, , text) # 去HTML标签 text re.sub(rhttps?://\S, , text) # 去URL text re.sub(r\s, , text) # 合并空白 return text.strip() df[clean_comment] df[comment].apply(clean_text) # 删除清洗后为空的内容 df df[df[clean_comment].str.len() 1] # 同理清洗标题字段 df[movie_name] df[movie_name].apply(clean_text) # 按评论评分去重 df df.drop_duplicates(subset[clean_comment, rating])清洗之后就是构造标签。豆瓣评分1到5星常见做法是把1星和2星归为负向4星和5星归为正向3星直接删除。原因是3星评论往往表达的是“还行、凑合、中性”这类样本边界模糊强塞进正负类会让朴素贝叶斯学出一堆噪声。df df[df[rating] ! 3] df[label] df[rating].apply(lambda x: 1 if x 4 else 0) print(df[label].value_counts())标签映射之后要检查类别分布。类别差距很大的时候后面要做重采样或调class_weight。如果你发现正向评论数量是负向的三倍以上先别急着补样本先看数据集整体规模如果总量超过几千条多数情况下用分层抽样划分训练集就能缓解。3. 朴素贝叶斯为什么适合干这个条件独立假设到三种变体选型3.1 贝叶斯公式与条件独立一个看起来很“玄学”的假设朴素贝叶斯的核心是把后验概率$P(类别|文本)$转化为先验概率和似然概率的乘积。对于影评情感分析我们想知道的是给定一段评论文本“剧情拖沓但演员演技在线”它属于正向的概率大还是负向的概率大。贝叶斯公式写出来是$$ P(类别|文本) \frac{P(文本|类别) \times P(类别)}{P(文本)} $$实际操作里分母$P(文本)$对所有类别都一样比较时直接忽略。“朴素”两个字来自条件独立假设假设文本里的每个词在给定类别下相互独立。这在语言学上明显不成立——“演技”和“演员”几乎总一起出现但贝叶斯假设它们互不影响。这个假设很粗糙却让计算量从指数级降到线性级也正是因为这种简单模型在小样本和高维稀疏数据上反而不容易过拟合。3.2 三种朴素贝叶斯变体BernoulliNB、MultinomialNB与补集朴素贝叶斯sklearn里最常用的朴素贝叶斯有三种选错变体是新手常见的“黑匣子”体验——模型能跑但效果差得莫名其妙。用表格说清楚适用场景变体特征含义适合场景影评项目里的表现BernoulliNB词是否出现0/1短文本、特征非常稀疏一般丢失了词频信息MultinomialNB词出现次数文本分类默认选择大多数时候效果最稳ComplementNB用“补集”概率估计类别不均衡时负向样本少时明显优于前两者MultinomialNB是默认首选它的概率估计直接基于词频适合影评这种以词频为主要信号的场景。BernoulliNB更适合判断“某个词有没有出现”比“出现了几次”更有意义的任务比如垃圾邮件过滤。ComplementNB的思路不一样它不直接算这个类别下词语的概率而是算“不属于这个类别”的概率类别不均衡时效果更好。如果负向评论占比很低优先试ComplementNB。from sklearn.naive_bayes import MultinomialNB, ComplementNB, BernoulliNB from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.pipeline import Pipeline pipeline Pipeline([ (tfidf, TfidfVectorizer()), (clf, MultinomialNB()) ])Pipeline在这里的意义是把文本向量化和分类器绑定在一起后面做交叉验证时能保证每次都在训练折上拟合向量化器避免数据泄露。3.3 先定评估指标再进特征工程别被准确率骗了情感分析项目里准确率是最不靠谱的指标。假设数据里有80%正向评论你把所有样本都预测成正向准确率也有80%。这不是模型学会了判断只是学会了偷懒。我通常把ROC-AUC和F1-score作为主指标。ROC-AUC衡量模型区分正负类的能力不依赖具体阈值F1-score在正负类样本不均衡时比准确率真实很多。这些指标不用在训练阶段管但要提前定好后面的特征工程和调参都是在为它们服务。4. 预处理与特征工程从分词到TF-IDF再到一个容易翻车的细节4.1 分词与停用词jieba不是银弹词典才是中文文本不能像英文那样按空格切词必须先用分词工具。jieba是使用率最高的选择但默认词典对影评领域词覆盖很差。像“烂片”“神作”“意难平”“降智”这类词很可能被切成“烂”“片”或者“意”“难”“平”语义就碎了。解决办法是给jieba加自定义词典。把影评领域的高频词和情感词放进一个txt文件每行一个词加载时用load_userdict读进来。import jieba jieba.load_userdict(movie_terms.txt) def tokenize(text): return [w for w in jieba.lcut(text) if w.strip()]自定义词典里我通常会放三类词电影类型词“悬疑片”“科幻片”、情感评价词“看完”“演技派”“烂尾”、以及网络语境下的情绪词“救命”“封神”“别去”。这里有个观点可以记一下分词不用追求完美朴素贝叶斯对分词错误的容忍度比深度学习模型高很多因为它是词袋模型分词边界错了顶多少一个特征不会把整个语义结构带偏。4.2 特征表示CountVectorizer与TfidfVectorizer的取舍分词之后文本要变成模型能吃的数值矩阵。两种主流做法CountVectorizer统计词频TfidfVectorizer在词频基础上加上逆文档频率权重。Tfidf的作用是压低那些在几乎所有评论里都出现的词的分量比如“电影”“一个”“真的”这种词同时抬高高频出现在某一类评论里的判别性词。from sklearn.feature_extraction.text import TfidfVectorizer vectorizer TfidfVectorizer( tokenizertokenize, max_features5000, min_df2, max_df0.8, ngram_range(1, 2) )参数说明max_features5000限制特征数量防止矩阵过于稀疏min_df2表示词至少在2条评论里出现过滤只出现一次的噪声词max_df0.8表示去掉在80%以上评论里都出现的词这类词基本没有类别区分度ngram_range(1, 2)同时保留单词和相邻双词组合像“演技”“在线”是单词特征“演技在线”就是双词特征能捕捉一点局部语序。4.3 特征工程的翻车细节把训练集和验证集一起fit等于提前交了卷这是我在这个项目里踩过最深的坑。初学者习惯先对全部数据做fit_transform再切分训练集和测试集。这样做会让TfidfVectorizer先“看”过测试集计算出全局的词频统计再让模型用包含测试集信息的特征去做预测。测试集就不再是未知数据了模型效果虚高上了真实环境立刻现原形。正确做法是先切分再在训练集上fit在测试集上只做transform。用Pipeline有个天然好处它会在每次交叉验证时自动对训练折做fit对验证折做transform不需要你手动管理这个流程。from sklearn.model_selection import train_test_split X_train, X_test, y_train, y_test train_test_split( df[clean_comment], df[label], test_size0.2, random_state42, stratifydf[label] )stratifydf[label]做分层抽样确保训练集和测试集里正负样本比例一致。random_state42固定随机种子让结果可复现。如果你在后续实验中发现效果波动很大优先检查这里是否做了分层。5. 训练与调参避坑准确率卡在83%我查了这四个地方5.1 现象一报错“ValueError: y contains NaN”训练时突然报这个错大概率不是模型的问题而是标签里有空值。很多人用df[label] df[rating].apply(...)构造标签却忘了先处理评分缺失的行。原因豆瓣数据里部分评论没有评分字段apply映射后产生NaN而朴素贝叶斯的fit不接受NaN标签。解决df df.dropna(subset[rating]) df[label] df[rating].apply(lambda x: 1 if x 4 else 0) df df[df[label].isin([0, 1])]检查完缺失再构造标签顺序很重要。5.2 现象二预测结果全变成一类模型在偷懒如果测试集里所有样本都被预测成正向最直接的原因是训练集类别严重不均衡正向评论占了绝大多数。朴素贝叶斯优化的是整体准确率发现全预测成正向就够准了就不会去学负向模式。解决先看类别分布如果负向占比低于20%做类别重加权或者换用ComplementNB。sklearn里MultinomialNB没有直接设置类别权重的参数但ComplementNB天然适合这个场景。from sklearn.naive_bayes import ComplementNB pipeline_cnb Pipeline([ (tfidf, TfidfVectorizer(tokenizertokenize, max_features5000, ngram_range(1, 2))), (clf, ComplementNB()) ])另外一个有效做法是负向样本复制或Synthetic Minority Over-sampling Technique但对文本数据复制样本容易过拟合我的经验是先试ComplementNB不行再动样本。5.3 现象三训练集AUC接近1测试集只有0.8这是典型过拟合表现。原因通常不是模型太复杂而是特征工程里出了问题要么是TfidfVectorizer提前在全量数据上fit过要么是train_test_split之前先做了停用词过滤导致数据泄露。解决把整个预处理流程放进Pipeline让交叉验证对每一折独立做向量化。另外可以把max_features调低比如从5000降到3000观察测试集效果变化。朴素贝叶斯本身正则化能力很强如果训练集效果太好而测试集掉点明显九成是流程设计问题不是模型超参数问题。5.4 现象四分词结果里全是“电影”“真的”“这部”停用词没生效在特征工程里加了一大份中文停用词表跑完却看到特征权重最高的词还是“电影”“真的”“这部”这说明停用词被加错了位置。很多人把停用词过滤放在jieba分词的前面而实际上停用词表必须作为TfidfVectorizer的参数传入或者在分词函数里过滤。正确做法stop_words set() with open(stopwords.txt, encodingutf-8) as f: for line in f: stop_words.add(line.strip()) def tokenize_with_stopwords(text): words jieba.lcut(text) return [w for w in words if w.strip() and w not in stop_words] vectorizer TfidfVectorizer( tokenizertokenize_with_stopwords, max_features5000, min_df2, max_df0.8 )注意TfidfVectorizer也支持stop_words参数直接传停用词列表但让分词函数在源头过滤掉能够减少噪声词进入后续统计环节内存占用也小一些。5.5 调参的一个靠谱顺序先管数据再管参数网格搜索放在最后参数范围也不需要很大。MultinomialNB只有一个关键超参数alpha它控制拉普拉斯平滑的强度默认值是1.0。alpha越大概率越向均匀分布靠拢泛化能力增强但可能丢失细节alpha越小模型越相信训练数据里的词频统计容易过拟合。from sklearn.model_selection import GridSearchCV param_grid { tfidf__max_features: [3000, 5000], tfidf__ngram_range: [(1, 2), (1, 3)], clf__alpha: [0.1, 0.5, 1.0] } grid GridSearchCV(pipeline, param_grid, cv5, scoringroc_auc, n_jobs-1) grid.fit(X_train, y_train) print(grid.best_params_)scoringroc_auc很重要如果这里用默认的accuracy选出来的参数很可能偏向多数类。cv5是常规选择数据量小的时候可以用3。n_jobs-1让所有CPU内核都参与计算省时间。6. 验证模型不只看准确率PR曲线、置信度阈值与误判样本回读模型训练完最后一步是从“能跑”到“可信”。我的习惯是做完准确率之外的两件事一是画PR曲线判断阈值取多少合适二是把预测错的样本捞出来逐条看。这两件事能暴露很多定量指标看不出的问题。from sklearn.metrics import precision_recall_curve, auc import matplotlib.pyplot as plt y_proba grid.predict_proba(X_test)[:, 1] precision, recall, thresholds precision_recall_curve(y_test, y_proba) pr_auc auc(recall, precision) print(fPR AUC: {pr_auc:.4f})PR曲线和ROC曲线关注点不同。ROC更看重“负样本被误判为正样本”的比例PR曲线则直接聚焦“预测为正向的评论里有多少真的正向”。当负向评论少的时候PR曲线比ROC更敏感模型在类别不均衡下的真实能力在PR曲线上看得更清楚。拿到概率值后可以做阈值校准。sklearn默认用0.5作为分类阈值但情感分析场景里这个阈值不一定最优。如果业务希望“宁缺毋滥”只把高置信度评论判为负向就把阈值调高比如0.7以上才预测为负向这样精确率更高召回率会相应下降。import numpy as np best_idx np.argmax(precision - recall) # 精确率与召回率平衡点 best_threshold thresholds[best_idx] pred_adjusted (y_proba best_threshold).astype(int)这段代码用“精确率减召回率最大”的位置当作平衡阈值是一种实用经验规则。真正上线时阈值应该根据业务需求决定但项目实践阶段用这个规则可以快速找到参考值。最后一步也是我每次必做的一步把测试集里预测错的评论打印出来按错判类型分组看。results pd.DataFrame({ comment: X_test, true_label: y_test, pred_label: grid.predict(X_test), prob: y_proba }) errors results[(results[true_label] ! results[pred_label])] for _, row in errors.head(10).iterrows(): print(f真实: {row[true_label]}, 预测: {row[pred_label]}, 概率: {row[prob]:.2f}) print(row[comment]) print(---)这个回读过程会让你发现模型错判的往往不是“情感判断错”而是“表达方式太隐晦”。比如“看完我沉默了很久”这种评论没有直接出现“烂”或“好”的字眼但语境里是负面评价词袋模型很难捕捉这种语气。看到这类样本我就知道该往特征工程里补什么词了。这个项目做到最后给我留下最深印象的不是朴素贝叶斯本身而是“数据到特征”这段路里每一个看似不起眼的决定都可能比模型选择更影响最终效果。分词词典、标签映射、分层抽样、阈值校准每一步都值得停下来验证。希望帮到你。本文还有配套的精品资源点击获取