ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

基于Python爬虫与深度学习的豆瓣影评情感分析实战

基于Python爬虫与深度学习的豆瓣影评情感分析实战 简介基于Python爬虫与深度学习方法的豆瓣影评分析项目围绕数据采集、文本清洗、情感分类与可视化展示提供一套完整的高校课程设计/毕业设计解决方案。面向AI、通信、自动化、电子信息、物联网等专业学生与科研人员既可用于项目演示也适合编程进阶者学习。压缩包共69个文件约1.54MB其中46个Python脚本覆盖爬虫、数据处理、模型训练与评估、可视化等环节5个Markdown文档提供说明与排错指引另附设计报告docx、图片样例、环境配置文件及代码规范文件整体结构清晰、便于按模块查阅。核心代码包含CNN、RNN、BERT等深度学习模型并配有简单UI界面与工具脚本运行稳定、易扩展。其中yaml环境配置与pylintrc规则文件有助于快速搭建环境和规范代码降低复现门槛。已有42人学习浏览可直接用于答辩展示或作为毕业设计初稿也利于二次开发。1. 为什么拿豆瓣影评练手数据由爬取到分析的完整闭环大部分 Python 爬虫教程会在“把网页存成 CSV”那一刻结束可课程设计和毕业设计要的是完整闭环从爬取、清洗、建模到可视化每一步都要能解释、能复现。豆瓣影评恰恰是这个链条成本最低的样本之一。它既是中文情感分析的标准场景也是动态加载和反爬策略的典型教材——用 requests 爬虫抓 XHR 接口用 bs4 解析静态页把文本清洗成监督数据再交给 TextRNN 或 BERT 做情感分类最后用图表和 UI 展示结果。项目源码里的 random_douban_spider_tool.py、pre_data_clean_tool.py、train_eval.py、MY_UI.py基本覆盖了这条链路。适合计算机相关专业的学生也适合想从“只写爬虫脚本”进阶到“能跑通 NLP 项目”的开发者和研究人员。2. 爬虫层设计与反爬应对requests、bs4 与并发抓取2.1 数据来自哪里短评 XHR 接口与 HTML 页面的差别很多教程会让你直接 GET 豆瓣评论页再拿 BeautifulSoup 去解析.comment-item。真实环境里这样只能拿到前几条短评因为豆瓣影评列表是 Ajax 动态加载的完整数据藏在 XHR 接口里。用浏览器开发者工具切到 Network刷新页面后过滤comments就能看到一个 JSON 接口格式类似GET https://movie.douban.com/j/subject/1292052/comments?start0limit20statusPsortnew_score这个接口返回的 JSON 里有comments数组、total总数和next_start游标。用 requests 请求时注意拼params不要自己拼 URL否则遇到中文编码会出问题import requests # 只做连通性验证不要直接循环抓 url https://movie.douban.com/j/subject/1292052/comments params { start: 0, limit: 20, status: P, sort: new_score } headers {User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/122.0 Safari/537.36} resp requests.get(url, paramsparams, headersheaders, timeout5) print(resp.url) print(resp.status_code)这里params会被 requests 自动编码并拼到 URL 上statusP表示只看“看过”的用户短评sortnew_score表示按最新排序。timeout5必须写否则在后面的并发场景里任何一个慢请求都可能让整个线程池挂住。拿到 JSON 后直接用resp.json()取comments列表每条记录里的content、rating、author就是我们后续要用的字段。一个高频问题是直接用浏览器复制 Cookie 放进代码后过几天就失效因为豆瓣会在登录态变化时刷新会话。对课程设计来说匿名抓取短评接口通常够用不要把登录态伪装得太过复杂。2.2 请求头、Cookie 与频率控制豆瓣的风控主要看三件事User-Agent 像不像浏览器、请求频率是不是人类、Referer 是不是从电影页跳过来。缺 UA 的裸请求几乎立刻返回 418 或自动跳转登录页。经典请求头配置如下headers { User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36, Referer: https://movie.douban.com/subject/1292052/, Accept: application/json, text/plain, */*, Accept-Language: zh-CN,zh;q0.9, Cookie: your_session_cookie }注意Referer要跟着请求目标变化不同电影 ID 对应不同的 subject 页面。Cookie可以直接从浏览器 DevTools 里复制但不要把它写进公开仓库课程设计提交代码时记得用环境变量占位比如os.getenv(DOUBAN_COOKIE)。频率控制比 Header 更重要豆瓣对同一 IP 的短时间请求非常敏感通常做法是每次请求前随机睡 1 到 3 秒import random import time def fetch_with_interval(url, headers, params, min_interval1, max_interval3): time.sleep(random.uniform(min_interval, max_interval)) resp requests.get(url, headersheaders, paramsparams, timeout5) resp.raise_for_status() return resp.json()raise_for_status()会在返回 4xx/5xx 时抛出异常避免你拿到错误页面还往下解析。random.uniform产生小数秒比固定 sleep 更像人类操作。下面的表列出了常见的反爬字段和对应策略实际项目里还能看到Sec-Fetch-*这类浏览器安全头如果被拦截再补也不迟。请求字段作用建议策略User-Agent标识浏览器类型用当前流行浏览器的真实 UAReferer告知来源页面填对应豆瓣电影页Cookie保持会话匿名或登录态均可失效就换Accept-Language语言协商填zh-CN,zh;q0.9X-Requested-With标识 Ajax 请求接口被拦时可以加XMLHttpRequest2.3 用 BeautifulSoup 解析长评静态页虽然短评走 XHR但长评页面仍然是传统 HTML这也正好给我们一个练 bs4 的场景。先把豆瓣电影页的长评列表拉下来再按选择器逐条提取from bs4 import BeautifulSoup url https://movie.douban.com/subject/1292052/reviews html requests.get(url, headersheaders, timeout5).text soup BeautifulSoup(html, html.parser) for item in soup.select(.review-item): title item.select_one(.review-title) content item.select_one(.review-short) rating item.select_one(.rating) if content is None: continue print(title.text.strip() if title else 无标题) print(content.text.strip()[:100]) print(rating.get(class) if rating else 无评分)select_one返回第一个匹配节点取不到时返回None所以要先判空再访问.text。.rating的评分不在title属性里而是在class列表里比如allstar40表示 4 星写代码时可以按前缀解析。豆瓣的 class 命名会随前端改版变化最稳的办法是先在本地把 HTML 保存下来用 bs4 的soup.prettify()确认选择器再写批量逻辑。bs4 爬取动态页面经常被误解实际上 bs4 只负责解析页面结构真正拿到动态数据还是要靠 XHR 接口所以本节开头先讲短评接口再补长评解析两个场景就都覆盖了。2.4 并发抓取的边界线程池与随机延时很多课程设计喜欢把并发写在标题里实际就是把max_workers调到 100结果请求一多直接封 IP。对豆瓣这种低容忍度站点并发不是越快越好。常见做法是用ThreadPoolExecutor开小规模线程池并发数控制在 4 到 8每次请求前随机延时再不断观察 HTTP 响应码和total字段from concurrent.futures import ThreadPoolExecutor, as_completed import random, time, requests base_url https://movie.douban.com/j/subject/1292052/comments def fetch_comments(start): time.sleep(random.uniform(1, 2)) resp requests.get( base_url, params{start: start, limit: 20, status: P, sort: new_score}, headersheaders, timeout5 ) data resp.json() return start, data.get(comments, []) start_list list(range(0, 101, 20)) all_comments [] with ThreadPoolExecutor(max_workers4) as executor: futures [executor.submit(fetch_comments, s) for s in start_list] for future in as_completed(futures): offset, comments future.result() all_comments.extend(comments) print(foffset {offset} 抓取到 {len(comments)} 条累计 {len(all_comments)} 条)as_completed用来在任务完成时立即处理结果而不是等全部跑完。max_workers4是经验值豆瓣短评接口在小并发下表现稳定再往上提速有限失败率却会明显上升。如果某天future.result()抛异常可以在外层包try/except并把失败的offset收进一个 list等第一批跑完后再补。爬虫并发设计到底哪个好没有统一答案核心是先压测目标站点能容忍的 QPS再确定线程数。分布式爬虫也是同样的思路无非是把队列调度改成 Redis 或消息队列但对课程设计来说单机小并发更容易控制风险。2.5 断点续抓与去重落盘爬虫跑到一半断网、被封或者程序崩溃是课程设计答辩前最容易遇到的事。解决方案是边抓边写不攒到最后一次性保存。推荐用 JSONL每行一个 JSON 对象而不是 CSV避免短评里的逗号和换行把表格结构撑坏import json import os def save_comment(comment, pathcomments.jsonl): seen set() if os.path.exists(path): with open(path, encodingutf-8) as f: for line in f: try: seen.add(json.loads(line)[id]) except (json.JSONDecodeError, KeyError): continue if comment[id] in seen: return False with open(path, a, encodingutf-8) as f: f.write(json.dumps({id: comment[id], content: comment[content]}, ensure_asciiFalse) \n) return True每次追加前都重新读一遍seen在数据量几万条时依旧很快。ensure_asciiFalse让中文直接落盘而不是变成\uXXXX方便你用 head 命令快速抽查。如果想要更强的去重可以用hashlib.md5(comment[content].encode()).hexdigest()代替id这样即使同一个用户改签再评内容重复也算重复。断点续抓的关键是让任务幂等同一批offset重复抓不会产生脏数据这也是生产级爬虫的核心设计之一。提示课程设计阶段不要堆机器也不要去研究平台封禁边界。把频率控制在每请求 1 至 3 秒爬几千条数据足够训练模型了。3. 影评数据清洗与预处理的落地细节从分词到标签化3.1 原始短评里有哪些噪声豆瓣短评看着干净实际处理起来噪声不少。常见的有HTML 实体amp;、URL 链接、表情符号占位符比如豆瓣表情的[星星眼]、繁体中文以及大量评论文本里的换行和多余空白。更麻烦的是短评长度极不均匀有的只有“好看”两个字有的写了一整段影评。如果直接把原始文本喂给模型词表会变得很碎UNK 比例过高模型学不到有效信号。清洗顺序比清洗强度更重要正确顺序是先剥掉结构性的内容标签、链接、表情占位再做字符级别的过滤最后才是分词和词性过滤。这一步对应的就是项目里的pre_data_clean_tool.py。3.2 正则去噪与标点规范写一个可复用的clean_text函数尽量把每一步拆成独立正则方便答辩时解释每一行在做什么import re def clean_text(text): text re.sub(r[^], , text) text re.sub(rhttps?://\S, , text) text re.sub(r\[.*?\], , text) text re.sub(r\s, , text) text re.sub(r[^\u4e00-\u9fa5a-zA-Z0-9。、\s], , text) return text.strip()第一个正则去 HTML 标签第二个去 URL第三个去类似[星星眼]的豆瓣表情占位符第四个把多个空白压缩成一个最后保留中文、英文、数字和常见中文标点。这里保留标点是为了后续按句切分如果你打算做基于整条短评的句子级预测也可以把标点全部去掉。需要说明的是[^...]是反向字符类意思是“除了这些字符都删掉”这在清洗中文文本时很常用。clean_text输出结果可以先抽样打印几十条确认没有误删“好看”里的感叹号再继续。3.3 jieba 分词与停用词过滤中文分词用 jieba 是最省事的方案。项目里的my_utils_fasttext.py还提示可以把分词结果进一步用于 fastText但当前任务需要的是给深度学习模型准备 token 序列jieba 足够。用jieba.posseg做词性过滤比单纯加载停用词更干净import jieba import jieba.posseg as pseg STOP_WORDS set() for line in open(stopwords.txt, encodingutf-8): word line.strip() if word: STOP_WORDS.add(word) def tokenize(text): tokens [] for word, flag in pseg.cut(text): if word in STOP_WORDS: continue if len(word) 1: continue if flag.startswith(n) or flag.startswith(v) or flag.startswith(a): tokens.append(word) return .join(tokens)flag.startswith(n)匹配名词、v匹配动词、a匹配形容词这样能滤掉“真的”“非常”“而已”等对情感贡献低的词。len(word) 1会把“好”“哭”这种单字词滤掉但像“燃”这样的单字在影评语境里其实很有情感倾向需要根据验证集效果决定是否放开。建议对电影名、导演名建立自定义词典比如jieba.load_userdict(movie_dict.txt)每行一个词避免“肖申克的救赎”被切碎。分词后要重新检查短评长度分布把长度超过 64 的截断长度小于 3 的直接删除这些统计信息可以输出成一张分布图作为预处理阶段的答辩素材。3.4 弱标注用评分构造情感标签豆瓣短评本身没有“正面/负面”标签但每条评论携带用户评分。常规做法是把 4 星、5 星视为正面1 星、2 星视为负面3 星视为中性并丢弃这样就把无监督文本转成了二分类监督数据。这是一种典型的弱标注虽然存在评分与文本不一致的情况但在课程设计中完全够用def label_mapping(rating_value): if rating_value 4: return 1 elif rating_value 2: return 0 else: return -1映射函数返回 -1 的样本在后续处理里直接丢弃。注意评分字段在不同抓取来源里格式不一样XHR 接口里可能是rating.value这个数字也可能是五星这种字符串所以label_mapping入口处最好先做类型归一化比如把力荐、推荐显式映射到 4/5把较差、很差映射到 1/2。弱标注的噪音会对准确率造成影响我一般会再做一次“清洗后情感极性一致性检查”用情感词典对分词结果打分如果与评分映射结果矛盾标记出来但不删除答辩时可以说明这是噪声样本。3.5 生成训练集并划分验证集预处理完成后的数据格式应该是三列文本、token 序列、标签。用 pandas 把 JSONL 读进来调sklearn.model_selection.train_test_split划分训练集和验证集from sklearn.model_selection import train_test_split import pandas as pd df pd.read_json(comments.jsonl, linesTrue) df[label] df[rating].apply(label_mapping) df df[df[label] ! -1].reset_index(dropTrue) df[clean] df[content].apply(clean_text) df[tokens] df[clean].apply(tokenize) df df[df[tokens].str.len() 3] train_df, val_df train_test_split( df, test_size0.1, random_state42, stratifydf[label] ) print(train_df[label].value_counts()) print(val_df[label].value_counts())stratifydf[label]让训练集和验证集的正负样本比例保持一致避免全是正面的验证集把模型准确率抬得虚高。random_state42保证每次运行划分结果一致答辩演示时不会“上次跑 0.85这次跑 0.79”。str.len() 3过滤掉分词后只剩一两个词的短评这类样本无法提供足够上下文。最终标签分布可以做成柱状图也可以输出成一张表放在设计报告里。原始评分映射标签含义建议处理5 星 / 4 星1正面保留作为正样本3 星-1中性丢弃避免模糊边界2 星 / 1 星0负面保留作为负样本4. 基于 TextRNN 与 BERT 的情感分析模型训练流程4.1 模型选型为什么先 TextRNN 再 BERT处理短文本情感分类一上来就用 BERT 不是最优策略。TextRNN 在 CPU 上也能快速训练能在十分钟内验证数据 pipeline 是否通顺而 BERT 微调需要 GPU且预训练权重要单独管理。最稳妥的课程设计组合是先用 TextRNN 跑通全流程拿到 baseline 准确率再加载 BERT 微调对比两种模型在同一验证集上的表现。项目里的models/TextRNN.py、models/bert.py和models/ERNIE.py就是为这种对比准备的。ERNIE 是百度的预训练模型中文能力更强但权重文件更大导出时要注意路径一致性。答辩时报告里可以写TextRNN 作为特征提取器 BERT 作为效果上限这样的选型逻辑比只堆模型更有说服力。4.2 TextRNN 模型结构Embedding BiLSTM Attention一个可直接运行的 TextRNN 类如下基于 PyTorch 实现import torch import torch.nn as nn class TextRNN(nn.Module): def __init__(self, vocab_size, embed_size128, hidden_size128, num_classes2, num_layers2, bidirectionalTrue, dropout0.3): super().__init__() self.embedding nn.Embedding(vocab_size, embed_size, padding_idx0) self.lstm nn.LSTM( embed_size, hidden_size, num_layersnum_layers, batch_firstTrue, dropoutdropout, bidirectionalbidirectional ) self.fc nn.Linear(hidden_size * (2 if bidirectional else 1), num_classes) self.dropout nn.Dropout(dropout) def forward(self, x): emb self.embedding(x) out, _ self.lstm(emb) out self.dropout(out[:, -1, :]) logits self.fc(out) return logitspadding_idx0会让 embedding 矩阵的第 0 行始终保持为 0对应预处理时在序列尾巴补的 0。batch_firstTrue让输入形状变成(batch_size, seq_len, embed_size)与 DataLoader 的返回维度对应。out[:, -1, :]取最后一个时间步的输出在双向 LSTM 里它包含两个方向的拼接向量因此fc输入维度是hidden_size * 2。dropout0.3只在训练时生效模型评估时会自动关闭。如果你后续要加 Attention可以在out[:, -1, :]前对out做加权的池化代码量不大但对调参更友好也可以作为报告里的优化点。4.3 训练循环与评估指标训练流程参考项目里的train_eval.py。核心循环可以精简如下from torch.utils.data import Dataset, DataLoader import torch.optim as optim import torch.nn.functional as F class ReviewDataset(Dataset): def __init__(self, texts, labels, vocab, max_len64): self.texts texts self.labels labels self.vocab vocab self.max_len max_len def __len__(self): return len(self.texts) def __getitem__(self, index): token_ids [self.vocab.get(w, 1) for w in self.texts[index].split()] token_ids token_ids[:self.max_len] token_ids token_ids [0] * (self.max_len - len(token_ids)) return torch.LongTensor(token_ids), torch.LongTensor([self.labels[index]]) model TextRNN(len(vocab)) optimizer optim.Adam(model.parameters(), lr1e-3) criterion nn.CrossEntropyLoss() for epoch in range(5): model.train() total_loss 0 for batch_text, batch_label in DataLoader(train_dataset, batch_size32, shuffleTrue): optimizer.zero_grad() logits model(batch_text) loss criterion(logits, batch_label.squeeze()) loss.backward() optimizer.step() total_loss loss.item() print(fepoch {epoch 1} loss: {total_loss / len(train_loader):.4f})self.vocab.get(w, 1)里的1是 UNK 的 index0是 PAD 的 index这样设计词表就避免了你把未知词误当成 padding。max_len64覆盖了大部分短评长度超过 64 的截断不足 64 的在尾部补 0。batch_size32在 CPU 上也比较舒适如果显存足够可以调到 64但准确率不一定线性提升。优化器用 Adam初始学习率 1e-3如果 loss 震荡不降降一半再跑。验证时用准确率和 F1 两个指标因为正负样本即使做了分层抽样也可能存在轻微不均衡。每轮保存一次 checkpoint保留最佳的验证损失模型而不是最后一个 epoch 的模型。4.4 BERT/ERNIE 微调与模型导出项目里包含 Bert 和 ERNIE 的模型文件以及bert_exporter.py和RNN_exporter.py说明设计者希望两个模型分别独立导出。微调阶段用pytorch_pretrained开头的包结构加载本地权重from pytorch_pretrained import BertForSequenceClassification, BertTokenizer model_dir bert_pretrain/ tokenizer BertTokenizer.from_pretrained(model_dir) model BertForSequenceClassification.from_pretrained(model_dir, num_labels2)model_dir里一般有vocab.txt、config.json和pytorch_model.bin。课程设计必须把预训练权重放到本地目录不要依赖在线下载否则答辩现场断网就会卡住。ERNIE 的加载逻辑和 BERT 几乎一样只是 tokenizer 不同。BERT 微调通常需要 3 到 5 个 epoch学习率比 TextRNN 小一个数量级建议lr2e-5。训练结束后用bert_exporter.py保存模型参数用它预测时要重新初始化模型结构再load_state_dict否则你只导出了权重却没有结构信息。下面是两种模型的对比适合写进设计报告的实验章节模型训练环境参数量验证集准确率导出格式TextRNNCPU 20 分钟约 1M0.82rnn_export.pthBERTGPU 3 小时约 110M0.89bert_export.bin这些数值是示例真实结果取决于你的数据量和清洗质量。BERT 准确率高但导出文件大预测速度慢TextRNN 胜在轻量。答辩时可以现场跑一条 TextRNN 预测作为演示再用 BERT 的结果作为精度对比不用真在答辩机器上跑几小时训练。5. 可视化与答辩演示从日志到 Web UI 的最后一公里5.1 训练曲线与词云展示训练日志如果只是打印在终端里答辩时不好回放。我一般会把每个 epoch 的 loss、准确率写进train.log再用visualization_tool.py读取并画图。词云是一个效果极佳的情感可视化方式正面和负面词云并排放在 PPT 里比任何表格都直观from wordcloud import WordCloud import matplotlib.pyplot as plt positive_text .join(train_df[train_df[label] 1][tokens].sum()) wc WordCloud(font_pathmsyh.ttc, width800, height600, background_colorwhite).generate(positive_text) plt.axis(off) plt.imshow(wc) plt.savefig(wordcloud_positive.png, dpi150)font_path必须使用中文字体Windows 常见是msyh.ttcLinux 常见是wqy-microhei.ttc找不到字体时词云里全是被切割的方框。dpi150保证生成图片直接放到 PPT 不模糊。词云只能展示词频不能展示情感强度所以最好再配一张正负类别的高频词 Top 30 条形图这比单纯词云更有说服力。5.2 用 MY_UI.py 快速搭一个演示界面项目里的MY_UI.py可以理解为 Tkinter 版本的最小交互界面也可以迁移成 Flask。演示的核心是输入一句新影评立刻返回正面概率。下面是一个 Tkinter 的骨架import tkinter as tk from model_service import predict def on_click(): text entry.get() if not text.strip(): return score predict(text) label.config(textf正面情感得分{score:.2f}) root tk.Tk() root.title(豆瓣影评情感分析) entry tk.Entry(root, width50) button tk.Button(root, text分析, commandon_click) label tk.Label(root, text请输入影评) entry.pack() button.pack() label.pack() root.mainloop()predict函数必须与训练时的预处理完全一致先clean_text再tokenize再查词表转 id。这里最容易翻车的是训练时用 pandas 处理好了文本预测时却忘了做同一条清洗链路导致模型看到的是乱码序列。建议把所有预处理步骤封装成Preprocessor类放在my_utils.py中训练和 UI 共用同一个实例。predict内部还要区分模型类型如果模型是 TextRNN 导出文件就用RNN_exporter.py的加载逻辑如果是 BERT 导出文件就用bert_exporter.py。这样无论答辩现场用桌面 UI 还是临时改成 Flask 网页 Demo预测结果都能保持一致。本文还有配套的精品资源点击获取
返回列表