简介:基于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?start=0&limit=20&status=P&sort=new_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, params=params, headers=headers, timeout=5) print(resp.url) print(resp.status_code)这里params会被 requests 自动编码并拼到 URL 上,status=P表示只看“看过”的用户短评,sort=new_score表示按最新排序。timeout=5必须写,否则在后面的并发场景里,任何一个慢请求都可能让整个线程池挂住。拿到 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;q=0.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_interval=1, max_interval=3): time.sleep(random.uniform(min_interval, max_interval)) resp = requests.get(url, headers=headers, params=params, timeout=5) resp.raise_for_status() return resp.json()raise_for_status()会在返回 4xx/5xx 时抛出异常,避免你拿到错误页面还往下解析。random.uniform产生小数秒,比固定 sleep 更像人类操作。下面的表列出了常见的反爬字段和对应策略,实际项目里还能看到Sec-Fetch-*这类浏览器安全头,如果被拦截再补也不迟。
| 请求字段 | 作用 | 建议策略 |
|---|---|---|
| User-Agent | 标识浏览器类型 | 用当前流行浏览器的真实 UA |
| Referer | 告知来源页面 | 填对应豆瓣电影页 |
| Cookie | 保持会话 | 匿名或登录态均可,失效就换 |
| Accept-Language | 语言协商 | 填zh-CN,zh;q=0.9 |
| X-Requested-With | 标识 Ajax 请求 | 接口被拦时可以加XMLHttpRequest |
2.3 用 BeautifulSoup 解析长评静态页
虽然短评走 XHR,但长评页面仍然是传统 HTML,这也正好给我们一个练 bs4 的场景。先把豆瓣电影页的长评列表拉下来,再按选择器逐条提取:
from bs4 import BeautifulSoup url = 'https://movie.douban.com/subject/1292052/reviews' html = requests.get(url, headers=headers, timeout=5).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'}, headers=headers, timeout=5 ) data = resp.json() return start, data.get('comments', []) start_list = list(range(0, 101, 20)) all_comments = [] with ThreadPoolExecutor(max_workers=4) 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(f'offset {offset} 抓取到 {len(comments)} 条,累计 {len(all_comments)} 条')as_completed用来在任务完成时立即处理结果,而不是等全部跑完。max_workers=4是经验值,豆瓣短评接口在小并发下表现稳定,再往上提速有限,失败率却会明显上升。如果某天future.result()抛异常,可以在外层包try/except并把失败的offset收进一个 list,等第一批跑完后再补。爬虫并发设计到底哪个好,没有统一答案,核心是先压测目标站点能容忍的 QPS,再确定线程数。分布式爬虫也是同样的思路,无非是把队列调度改成 Redis 或消息队列,但对课程设计来说,单机小并发更容易控制风险。
2.5 断点续抓与去重落盘
爬虫跑到一半断网、被封或者程序崩溃,是课程设计答辩前最容易遇到的事。解决方案是边抓边写,不攒到最后一次性保存。推荐用 JSONL(每行一个 JSON 对象)而不是 CSV,避免短评里的逗号和换行把表格结构撑坏:
import json import os def save_comment(comment, path='comments.jsonl'): seen = set() if os.path.exists(path): with open(path, encoding='utf-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', encoding='utf-8') as f: f.write(json.dumps({'id': comment['id'], 'content': comment['content']}, ensure_ascii=False) + '\n') return True每次追加前都重新读一遍seen,在数据量几万条时依旧很快。ensure_ascii=False让中文直接落盘而不是变成\uXXXX,方便你用 head 命令快速抽查。如果想要更强的去重,可以用hashlib.md5(comment['content'].encode()).hexdigest()代替id,这样即使同一个用户改签再评,内容重复也算重复。断点续抓的关键是让任务幂等:同一批offset重复抓不会产生脏数据,这也是生产级爬虫的核心设计之一。
提示:课程设计阶段不要堆机器,也不要去研究平台封禁边界。把频率控制在每请求 1 至 3 秒,爬几千条数据足够训练模型了。
3. 影评数据清洗与预处理的落地细节:从分词到标签化
3.1 原始短评里有哪些噪声
豆瓣短评看着干净,实际处理起来噪声不少。常见的有:HTML 实体(&)、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(r'https?://\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', encoding='utf-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', lines=True) df['label'] = df['rating'].apply(label_mapping) df = df[df['label'] != -1].reset_index(drop=True) 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_size=0.1, random_state=42, stratify=df['label'] ) print(train_df['label'].value_counts()) print(val_df['label'].value_counts())stratify=df['label']让训练集和验证集的正负样本比例保持一致,避免全是正面的验证集把模型准确率抬得虚高。random_state=42保证每次运行划分结果一致,答辩演示时不会“上次跑 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_size=128, hidden_size=128, num_classes=2, num_layers=2, bidirectional=True, dropout=0.3): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_size, padding_idx=0) self.lstm = nn.LSTM( embed_size, hidden_size, num_layers=num_layers, batch_first=True, dropout=dropout, bidirectional=bidirectional ) 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_idx=0会让 embedding 矩阵的第 0 行始终保持为 0,对应预处理时在序列尾巴补的 0。batch_first=True让输入形状变成(batch_size, seq_len, embed_size),与 DataLoader 的返回维度对应。out[:, -1, :]取最后一个时间步的输出,在双向 LSTM 里它包含两个方向的拼接向量,因此fc输入维度是hidden_size * 2。dropout=0.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_len=64): 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(), lr=1e-3) criterion = nn.CrossEntropyLoss() for epoch in range(5): model.train() total_loss = 0 for batch_text, batch_label in DataLoader(train_dataset, batch_size=32, shuffle=True): optimizer.zero_grad() logits = model(batch_text) loss = criterion(logits, batch_label.squeeze()) loss.backward() optimizer.step() total_loss += loss.item() print(f'epoch {epoch + 1} loss: {total_loss / len(train_loader):.4f}')self.vocab.get(w, 1)里的1是 UNK 的 index,0是 PAD 的 index,这样设计词表就避免了你把未知词误当成 padding。max_len=64覆盖了大部分短评长度,超过 64 的截断,不足 64 的在尾部补 0。batch_size=32在 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_labels=2)model_dir里一般有vocab.txt、config.json和pytorch_model.bin。课程设计必须把预训练权重放到本地目录,不要依赖在线下载,否则答辩现场断网就会卡住。ERNIE 的加载逻辑和 BERT 几乎一样,只是 tokenizer 不同。BERT 微调通常需要 3 到 5 个 epoch,学习率比 TextRNN 小一个数量级,建议lr=2e-5。训练结束后用bert_exporter.py保存模型参数,用它预测时要重新初始化模型结构再load_state_dict,否则你只导出了权重却没有结构信息。下面是两种模型的对比,适合写进设计报告的实验章节:
| 模型 | 训练环境 | 参数量 | 验证集准确率 | 导出格式 |
|---|---|---|---|---|
| TextRNN | CPU 20 分钟 | 约 1M | 0.82 | rnn_export.pth |
| BERT | GPU 3 小时 | 约 110M | 0.89 | bert_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_path='msyh.ttc', width=800, height=600, background_color='white').generate(positive_text) plt.axis('off') plt.imshow(wc) plt.savefig('wordcloud_positive.png', dpi=150)font_path必须使用中文字体,Windows 常见是msyh.ttc,Linux 常见是wqy-microhei.ttc,找不到字体时词云里全是被切割的方框。dpi=150保证生成图片直接放到 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(text=f'正面情感得分:{score:.2f}') root = tk.Tk() root.title('豆瓣影评情感分析') entry = tk.Entry(root, width=50) button = tk.Button(root, text='分析', command=on_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,预测结果都能保持一致。
本文还有配套的精品资源,点击获取