☰
豆瓣短评情感分析实战:爬虫、情感模型与词云避坑指南
2026/10/12 21:49:26 网站建设 项目流程

简介:一份基于Python的豆瓣电影评论情感分析与关键词提取WordCloud设计源码,面向NLP、文本挖掘及数据可视化方向的开发者与研究人员。项目围绕main.py构建完整处理链路:先进行评论清洗、去停用词等预处理,再结合TF-IDF/TextRank等方法提取关键词,利用机器学习模型判别情感极性,最后以词云图、情感分布图和趋势图呈现结果。压缩包共39个文件,包括1个py主程序、19个PNG图表(词云图、情感分布图、趋势图)、6个xlsx分析报告、4个xml文件、4个txt文本(含停用词表、过滤文本)、2个otf中文字体及IDE辅助配置,整体大小约44.31MB,按功能模块组织,便于逐项研读。目前已有183人学习,资源内已集成唐探1900、哪吒之魔童闹海、熊出没·重启未来等六部电影的真实评论分析案例,覆盖从数据预处理、特征提取、模型训练到报告输出的完整流程,适合用于对照学习情感分析管线、关键词权重计算及中文词云渲染技巧。

1. 基于 Python 的豆瓣短评情感分析与 wordcloud 词云:别把模型输出当真

基于 Python 的豆瓣电影评论情感分析与关键词提取,再配一张 wordcloud 词云图,是我见过最适合 Python 入门者练手的真实数据项目。它能解决一个具体问题:把某部电影几千条短评抓下来,判断整体口碑是正向还是负向,找出观众反复提到的点,最后把高频词画成一张能放到汇报里的词云图。但别急着开跑,默认的情感模型拿去做电影短评经常翻车,这是这个项目里第一个值得你记住的教训。它适合刚学完基础语法、想用真实数据把 requests、jieba、pandas 串起来的人,也适合需要快速出舆情结论的运营同学。

2. 把短评抓下来:豆瓣评论区的请求头、翻页与清洗落库

2.1 先看数据结构:短评藏在 HTML 的哪个节点里

豆瓣短评页的常见做法是直接抓网页 HTML,而不是调用 App 端签名接口。评论主体都在div.comment-item这个节点里,每条评论包含正文、星级、时间和点赞数。用浏览器打开任意一部电影的短评页,按 F12 看到的 DOM 结构大致是:

<div class="comment-item">import time import random 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.0.0 Safari/537.36" ), "Referer": "https://movie.douban.com/", "Cookie": "你的登录 Cookie,建议从浏览器复制完整值", } def fetch_comments(movie_id: str, start: int = 0, limit: int = 20) -> list[dict]: url = f"https://movie.douban.com/subject/{movie_id}/comments" params = { "start": start, "limit": limit, "sort": "new_score", "status": "P", } resp = requests.get(url, params=params, headers=headers, timeout=10) resp.raise_for_status() soup = BeautifulSoup(resp.text, "html.parser") result = [] for item in soup.select("div.comment-item"): short_el = item.select_one("span.short") rating_el = item.select_one(".comment-info span.rating") time_el = item.select_one(".comment-time") if short_el is None: continue rating = 0 if rating_el is not None: for cls in rating_el.get("class", []): if cls.startswith("allstar"): rating = int(cls.replace("allstar", "")) / 10 result.append({ "text": short_el.get_text(strip=True), "rating": rating, "time": time_el.get("title", "") if time_el is not None else "", }) return result

这段代码里,params的四个参数都有具体含义:start控制偏移量,第 0 条开始;limit每次取 20 条,豆瓣对单次分页上限控制得很保守;sort=new_score表示按热门短评排序,sort=time则按时间排序;status=P只看“看过”的用户评论,这样能过滤掉没看过就打分的水评。resp.raise_for_status()会在状态码非 200 时直接抛异常,方便在批量翻页时快速发现被封。list[dict]类型标注在 Python 3.8 里已经不需要from typing import List,直接写就行。

2.3 翻页循环、限速与存储为结构化 CSV

单页 20 条显然不够,翻页循环里真正重要的不是循环本身,而是两个保护措施:空结果立即停止、每次请求之间随机休眠。把start从 0 递增到几百,每步加 20,直到某页返回空列表就 break。不要在拿到一批数据后立刻发起下一次请求,time.sleep(random.uniform(1, 2.5))是这条链路里性价比最高的反封号手段。

movie_id = "1292052" # 示例用《肖申克的救赎》,换成你要分析的 ID all_comments = [] for start in range(0, 260, 20): comments = fetch_comments(movie_id, start=start) if not comments: break all_comments.extend(comments) print(f"已获取 {len(all_comments)} 条") time.sleep(random.uniform(1, 2.5))

抓下来之后,最忌讳把数据直接塞进变量然后反复重跑抓取。把清洗后的结果落成结构化 CSV,后续情感分析和关键词提取都从文件读,抓取脚本只跑一次。字段就三个:text正文、rating星级、time完整时间。写入时用utf-8-sig而不是utf-8,否则用 Excel 打开 CSV 会看到中文乱码,这是 PC 端 Windows 的经典坑。

import csv with open("douban_short_comments.csv", "w", encoding="utf-8-sig", newline="") as f: writer = csv.DictWriter(f, fieldnames=["text", "rating", "time"]) writer.writeheader() writer.writerows(all_comments)

newline=""是 Python 写 CSV 时避免空行的标配写法,csv.DictWriter按字段名顺序写出字典列表。我的习惯是这套脚本按fetch / sentiment / wordcloud三个模块拆成独立文件,抓取模块输出原始 CSV,情感分析模块读入后输出带标签的新 CSV,最后一个模块只管出图。这样每一步都有中间产物,翻车时能清楚定位是数据问题还是模型问题,而不是对着一个混沌的长脚本干瞪眼。

3. 情感分析:SnowNLP 的阈值划分与自定义词典修正

3.1 为什么先用 SnowNLP,而不是一上来就上大模型或多模态方案

情感分析在这个项目里是第一主角,选型却最容易纠结。常见做法是先用 SnowNLP 跑一个离线可复现的基线,而不是一上来就接大模型 API。原因很实际:短评平均不到 30 个字,大模型在这种长度上优势不大,还要处理 key、配额和成本;多模态情感分析虽然能融合弹幕、语音转写和画面内容,但那需要额外采集渠道,入门阶段碰它等于给自己挖坑。先把文本基线立住,用真实结果说服自己,再谈升级。

SnowNLP 内部是朴素贝叶斯分类器,训练语料偏向电商评论,这是它最大的黑匣子,也是电影短评场景翻车的主要来源。同一个词在电商语境和影视语境里的情感极性完全可能相反,模型不知道“演技炸裂”是夸人。理解这一点比背 API 更重要,因为后面所有阈值调整和词典修正都在为这个偏差买单。在 Python 3.8 这类常见环境里,pip install snownlp之后直接 import 就能用,老库的好处就是没什么依赖陷阱。

3.2 跑通第一版:sentiments 分数、批量调用与 0.4/0.6 阈值

SnowNLP(text).sentiments返回 0 到 1 之间的浮点数,越接近 1 越正向。但 0.5 并不是天然的切割点,我的习惯是把 0.4 以下划为负向、0.6 以上划为正向、中间算中性。这个 0.4/0.6 的阈值比 0.5 单点切割更稳,因为短评里大量文本本来就模棱两可,硬把 0.52 算成正向会污染后续的词云对比。

import pandas as pd from snownlp import SnowNLP df = pd.read_csv("douban_short_comments.csv", encoding="utf-8-sig") df = df.dropna(subset=["text"]) df = df[df["text"].str.len() >= 2] def sentiment_score(text: str) -> float: return SnowNLP(text).sentiments df["sentiment"] = df["text"].apply(sentiment_score) df["label"] = pd.cut( df["sentiment"], bins=[0, 0.4, 0.6, 1.0], labels=["负向", "中性", "正向"], right=True, ) df.to_csv("comments_with_sentiment.csv", index=False, encoding="utf-8-sig") print(df["label"].value_counts())

pd.cut的三个区间边界是核心参数,right=True表示左开右闭,0.4 会被分到中性区间而不是负向。长度过滤str.len() >= 2是为了把“好”“烂”这类单字评论先筛掉,它们信息量太低,还容易让后面的关键词提取跑偏。批量调用 SnownLP 是逐条算的,1000 条短评大约要跑一两分钟,先把结果写回comments_with_sentiment.csv,后面反复调阈值就不用重新跑模型。

跑完看一眼label的分布,如果正向占比超出了直觉,不要立刻改阈值,先去看具体影评。分布整体偏正对电影来说正常,但如果一部口碑明显分裂的片子也被判成一窝蜂正向,那就是语料偏差在起作用,得进入下一步。

3.3 自定义词典与加权修正:把「演技炸裂」拉回正向

SnowNLP 的自定义训练接口需要准备大批标注语料,对入门项目性价比太低。我一般用词表加权做轻量修正:准备一个正向词集合和一个负向词集合,统计文本里命中次数,在模型原始分数上做加减。修正幅度要克制,一个词命中加 0.08,因为一次命中不该彻底推翻模型输出,只有多个同极性词同时出现才应该明显偏移。

POSITIVE_WORDS = {"演技炸裂", "神作", "经典", "震撼", "值得二刷", "惊喜"} NEGATIVE_WORDS = {"烂片", "浪费时间", "尴尬", "拖沓", "难看", "骗钱"} def adjusted_sentiment(text: str) -> float: base = SnowNLP(text).sentiments pos_hit = sum(1 for w in POSITIVE_WORDS if w in text) neg_hit = sum(1 for w in NEGATIVE_WORDS if w in text) score = base + pos_hit * 0.08 - neg_hit * 0.08 return round(max(0.0, min(1.0, score)), 4) df["sentiment"] = df["text"].apply(adjusted_sentiment) df["label"] = pd.cut( df["sentiment"], bins=[0, 0.4, 0.6, 1.0], labels=["负向", "中性", "正向"], right=True, )

0.08 这个步长的选择逻辑是:命中 3 个正面词才加 0.24,能把临界文本从负向拉到中性,但不会把一个明显的负向评语直接改写成正向,clamp到 0-1 是为了防止叠加越界。词表里的词要自己根据影片类型迭代,动作片和文艺片的常用评价词完全不同。另一个隐藏参数是词的长短,“炸裂”这种被拆分后语义反转的词,比“演技炸裂”更容易误伤,所以词表里优先放短语而不是单字词。

两个词集合不要有交集,否则同一条评语会同时加减,等于白做。验证修正效果的标准做法是用豆瓣星级当硬标签:4 星以上算正向,2 星以下算负向,然后和模型 label 做交叉表,看负向召回是否明显提升。具体代码放在最后一章,那里一起给出完整验证流程。

4. 关键词提取与 wordcloud 可视化:TF-IDF、停用词、中文字体

4.1 关键词提取选 TF-IDF 还是 TextRank:短评场景的取舍

关键词提取在短评场景里默认选jieba.analyse.extract_tags,也就是 TF-IDF 路线,而不是 TextRank。原因在于 TextRank 依赖词与词在文本窗口里的共现关系,短评单条太短,拼接成大文本后窗口关系混乱,提取结果往往不如 TF-IDF 稳定。TF-IDF 的核心逻辑是“在一个文档里频繁出现、但在整个语料里少见”的词更重要,这正好匹配短评里那些反复被观众提到的记忆点。

import jieba import jieba.analyse STOP_WORDS = set() with open("stopwords.txt", encoding="utf-8") as f: for line in f: word = line.strip() if word: STOP_WORDS.add(word) all_text = " ".join(df["text"].tolist()) keywords = jieba.analyse.extract_tags( all_text, topK=30, withWeight=True, allowPOS=("n", "vn", "v", "ns", "a"), ) keywords = [ (word, weight) for word, weight in keywords if word not in STOP_WORDS and len(word) >= 2 ]

topK=30决定词云最多考虑多少个词,withWeight=True让结果带上 TF-IDF 权重,后面喂给 wordcloud 时直接当词频用。allowPOS是词性过滤,n名词、vn动名词、v动词、ns地点、a形容词,这一组合拳能滤掉“我们”“真的”“什么”这类口语化高频词。这里有个隐藏细节:extract_tags本身没有内置停用词参数,常见做法是提取出结果后用自己维护的STOP_WORDS集合二次过滤,同时用len(word) >= 2挡住单字噪音。

停用词表不一定要下载现成的,我习惯先跑一版,把 top 词里明显无语义贡献的词手工补进 txt 再重跑。比如“电影”“一部”“感觉”这类词在不同项目里要按需决定是否保留,反而比通用表更贴合业务。跑完打印前 20 个词,如果还能看到代词和语气词,说明停用词表不够,这是后面避坑章节的核心话题之一。

4.2 wordcloud 参数:从字体到 mask 遮罩的完整配置

词云图翻车九成出在中文字体上。wordcloud默认字体不支持中文,不指定font_path的话输出全是方块。Windows 常见做法是指向simhei.ttf,macOS 指向PingFang.ttc,Linux 可以先执行fc-list :lang=zh查可用字体再填路径。字体这事没有玄学,就是每次换环境都别忘了确认一次。

import numpy as np from PIL import Image from wordcloud import WordCloud mask = np.array(Image.open("mask.png").convert("L")) wc = WordCloud( font_path="C:/Windows/Fonts/simhei.ttf", width=1200, height=800, max_words=100, background_color="white", mask=mask, colormap="RdYlGn", contour_width=0.5, contour_color="black", ) freq = {word: float(weight) for word, weight in keywords} wc.generate_from_frequencies(freq) wc.to_file("douban_wordcloud.png")
参数作用常见值
font_path中文字体路径,不设必乱码simhei.ttf / PingFang.ttc
mask词云轮廓的 numpy 数组白底黑轮廓的 PNG
max_words最多显示词数80-120
background_color画布底色white / 按汇报风格
colormap配色方案RdYlGn / viridis
contour_width轮廓线粗细0.5,不要轮廓则设 0

最关键的是generate_from_frequencies(freq)而不是generate(text)。generate会对传入的文本重新分词,如果你传入的是“词 词 词”这种用空格拼好的字符串,分词器会把整串当英文处理,词云直接碎掉。generate_from_frequencies接收的是{词: 权重}字典,权重是 0 到 1 还是几万都不影响,wordcloud 内部会自己归一化。mask.png建议用纯白底、黑色轮廓的图片,.convert("L")转灰度图能避免透明通道干扰轮廓识别。

4.3 让词云有业务意义:好评与差评各自出图

整批评论出一张词云只能看个热闹,真正能放进汇报的是正负向对比。按上一章的label字段把数据拆成两部分,分别拼文本、提取关键词、生成词云,输出两张图。这样一眼就能看出好评集中在“演技”“剧情”“经典”,差评集中在“拖沓”“特效”“烂尾”。

for label_name in ["正向", "负向"]: sub = df[df["label"] == label_name] text = " ".join(sub["text"].tolist()) kw = jieba.analyse.extract_tags( text, topK=30, withWeight=True, allowPOS=("n", "vn", "v", "ns", "a"), ) kw = [(w, f) for w, f in kw if w not in STOP_WORDS and len(w) >= 2] wc.generate_from_frequencies({w: float(f) for w, f in kw}) wc.to_file(f"wordcloud_{label_name}.png")

出图之后不要只看图好不好看,要回到数据看比例:如果负向短评只有 80 条,而正向有 500 条,差评词云的样本量可能撑不起 topK=30 的统计意义。此时可以把差评的 topK 调低到 15,或者干脆在图上标注样本量。文件命名里带上标签和日期,比如wordcloud_负向_20240115.png,防止迭代几轮后分不清哪张是旧图。这套流程跑通后,换任何一部电影都只需要改movie_id重跑,源码的组织方式决定了它的复用成本。

5. 避坑排查:编码、字体、反爬、情感偏差的五个典型翻车现场

5.1 视觉系翻车:词云方框与 CSV 乱码

现象:wordcloud 生成的 PNG 里全是方块,一个汉字都显示不出来。原因很直接:WordCloud 默认字体是 DroidSansMono,它没有中文字形。解决:把font_path参数指到系统里真实存在的中文字体文件。Windows 下常见路径是C:/Windows/Fonts/simhei.ttf,macOS 是/System/Library/Fonts/PingFang.ttc,Linux 先跑fc-list :lang=zh看有哪些字体再填。我一开始也以为换个字体就行,结果换了个不含中文字形的开源字体照样方块,后来养成了“出图前先检查字形”的习惯。

现象:CSV 文件用 Excel 打开,中文全乱码。原因:Windows Excel 默认用 GBK 读取 CSV,而 Python 写入时用的是 UTF-8。解决:写文件时把 encoding 从utf-8改成utf-8-sig,它会在文件开头加一个 BOM 标记,Excel 就能正确识别编码。另一个相关坑是短评正文里可能带换行符,会让一条评论在 CSV 里被拆成两行导致错位,写入前先text.replace("\n", " ").replace("\r", " ")处理一遍。这个坑非常隐蔽,因为 pandas 读回 CSV 时不会报错,只有后续按行统计时才发现条数对不上。

5.2 数据系翻车:403 与“请登录”,情感分数和星级打架

现象:翻页翻到一半,接口开始返回 403,或者页面里只剩“请登录”字样,评论列表为空。原因:豆瓣反爬对连续高频请求有限流,未登录的 Cookie 能看到的页数更少。解决:把两次请求之间的休眠时间拉长到random.uniform(1, 3),单次运行只抓 200 到 500 条,别开多线程硬刚。Cookie 会过期,我把它放到环境变量里而不是写死在源码中,失效后重新登录浏览器复制一次,修改成本很低。还有一种常见做法是每天跑一次增量采集,而不是一次性抓全量,这样既躲限流,又能让后续的情绪时间线分析有数据支撑。

现象:豆瓣星级打了 5 星的短评,SnowNLP 判成负向,情感分数和直觉明显矛盾。原因:SnowNLP 训练语料偏电商,“演技炸裂”里的“炸裂”在电商语境是负面的,模型不知道影视语境里它是夸人。解决:先用星级做硬标签交叉验证,看负向召回率,再用 3.3 节的自定义词表做加权修正。注意修正只解决高频词,如果整体分数分布系统性偏移,更稳的做法是把 0.4/0.6 阈值整体调整,比如把负向边界抬到 0.45。这里不要迷信黑匣子,模型输出永远只是特征,不是结论。

5.3 内容系翻车:停用词污染 topK

现象:关键词提取出来 top 10 全是“我们”“真的”“没有”“什么”,业务词一个都看不见。原因:短评是口语化文本,代词和副词出现频率天然高,TF-IDF 的 IDF 部分在数据量不够时压不住它们。解决:建停用词表,提取结果出来后二次过滤,同时用allowPOS限定词性。停用词表别指望一次建完,第一版跑完把噪音词手工补进去,来回迭代两三轮就稳定了。还有一个被忽略的细节:单字词要过滤,len(word) >= 2这个条件能把“烂”“好”“爽”挡在外面,它们在词云里既不好看也没统计意义。建议把停用词表单独存成 txt,每行一个词,方便在多个电影项目间复用。

6. 跑通之后的验证技巧:用星级硬标签和日期聚合检查分析没跑偏

6.1 交叉验证与每日情绪均值

词云好看不等于分析正确,我用两个方法验证整个 pipeline 有没有跑偏。第一个方法是星级硬标签交叉验证,把豆瓣星级转成真实情感标签,和 SnowNLP 的预测结果做交叉表,直接揭露模型在哪类评论上翻车。

rated = df[df["rating"] > 0].copy() rated["hard_label"] = rated["rating"].apply( lambda r: "正向" if r >= 4 else ("负向" if r <= 2 else "中性") ) print(pd.crosstab(rated["label"], rated["hard_label"]))

看交叉表时我关注两个数:负向短评里被模型正确召回的比例,以及正向短评的误杀率。某部文艺片可能有大量 3 星的“还行”被模型判成正向,这不是模型坏了,而是中性区间太窄。调整阈值到 0.35/0.65 再看交叉表,效果往往立竿见影。

第二个方法是日期聚合,把短评按天分组求平均情感分,存入 csv 后画一条折线。这个图能看出口碑随时间的变化,比如上映首日和次周的分差,比静态词云信息量大得多。

df["date"] = df["time"].str[:10] daily = df.groupby("date")["sentiment"].mean().reset_index() daily.to_csv("daily_sentiment.csv", index=False, encoding="utf-8-sig")

我第一次跑完整条链路时,兴奋地把词云图拿给同事看,结果 top 10 全是“我们”“真的”“没有”,被当场追问结论是什么。从那以后我养成两个习惯:任何词云出图之前,先跑停用词过滤;任何情感结论落地之前,先和豆瓣星级对一次交叉表。这个项目的源码本身不难,难的是不被中间产物骗过去。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询