简介:本资源是一套完整的豆瓣电影TOP250短评数据挖掘与中文情感分析实战项目,面向Python爬虫初学者、NLP入门者及数据分析爱好者,解决从网页抓取、多源数据清洗、统计可视化到多种主流模型构建的全流程实践问题。压缩包含214个文件(73.43MB),涵盖Scrapy爬虫脚本(.py)、结构化数据(.json)、分析结果图表(.png)、Jupyter实验笔记(.ipynb)、预训练词向量(.vec)及模型文件(.bin/.pyc),支持即开即用与模块化学习。已有3772人下载学习,项目严格按「movie_item–movie_comment–movie_people」三维度组织,不仅提供肖申克的救赎等典型影片的词云与多模型对比(朴素贝叶斯/SVM/FastText/CNN/RNN/GRU),还包含跨表协同分析、地域分布热力图、导演/演员/语言等多维统计及基于简介与短评的联合情感建模,具备完整工程闭环与可复现性。
1. 项目概述:从数据采集到情感洞察的全链路实践
最近在复盘几个数据项目时,我又把经典的豆瓣电影TOP250短评爬取与分析案例拿出来跑了一遍。这个项目之所以常做常新,是因为它几乎涵盖了数据工程与数据分析的一个标准微型闭环:从目标网站(豆瓣电影)定向采集数据(Scrapy爬虫),到对非规整的文本数据进行清洗与规整(数据清理),再到基础的描述性统计(数据分析),最后尝试构建一个能理解中文文本情感的模型(情感分析模型)。整个过程,就像把一堆原始的矿石(网页数据)开采出来,经过多道工序的提炼和加工,最终得到有商业或研究价值的金属(情感倾向洞察)。对于想入门数据科学,或者希望系统性掌握从数据获取到价值提取全流程的朋友来说,这是一个绝佳的练手项目。它不仅要求你懂点Python和爬虫,还考验你对脏数据的处理耐心,以及对自然语言处理(NLP)基础模型的理解与应用能力。
这个项目的核心价值在于其完整性和实用性。你最终得到的不是一个孤立的脚本或模型,而是一套可以复用于其他类似场景(如电商评论、社交媒体舆情)的方法论。通过它,你可以清晰地看到,数据是如何一步步从网页上的杂乱文字,变成结构化的表格,再转化为模型可以“理解”的特征,最终输出一个判断(正面、负面或中性)。接下来,我将拆解这个全链路过程中的每一个关键环节,分享我实际操作中积累的配置细节、踩过的坑以及提升效率的小技巧。
2. 项目整体设计与核心思路拆解
2.1 目标定义与技术栈选型
我们的终极目标是分析豆瓣TOP250电影短评的情感倾向。要实现它,需要拆解为四个顺序执行的阶段,每个阶段对应不同的技术任务:
- 数据采集:从豆瓣电影TOP250榜单页面开始,逐部电影地抓取其短评页面的数据,包括评论内容、评分、评论时间、有用数等。
- 数据清理与存储:爬取到的原始数据包含HTML标签、不规则字符、缺失值等,需要进行清洗。清洗后的规整数据需要持久化存储,供后续分析使用。
- 探索性数据分析:对清洗后的数据进行基本的统计分析,例如评分分布、评论长度分布、高频词汇等,以直观了解数据全貌。
- 情感分析模型构建:利用清理后的评论文本,训练或微调一个能够自动判断评论情感极性的分类模型。
基于以上任务,技术栈的选择就非常明确了:
- 爬虫框架:Scrapy。相比
requests+BeautifulSoup的手工组合,Scrapy是一个为大规模、结构化爬取而生的异步框架。它内置的请求调度、去重、管道(Pipeline)和中间件(Middleware)机制,能让我们更专注于数据提取规则(XPath/CSS选择器)的编写,而不用操心并发、队列等底层问题。对于豆瓣这种有一定反爬策略的网站,Scrapy的中间件可以方便地集成代理、自定义User-Agent等反反爬措施。 - 数据存储:SQLite / MySQL / CSV。对于这个量级的数据(250部电影 * 每部约20-40条评论,总计约数千到一万条),SQLite这种轻量级数据库完全够用,且便于移植。如果考虑后续扩展或团队协作,MySQL是更正式的选择。在初期快速验证阶段,直接导出为CSV文件进行分析也未尝不可。
- 数据分析:Pandas + Matplotlib/Seaborn。Pandas是Python数据分析的事实标准,其DataFrame结构非常适合进行数据清洗、转换和聚合。Matplotlib和Seaborn则用于将分析结果可视化,生成直观的图表。
- 情感分析模型:Jieba + Sklearn / Transformers。中文文本处理首先要分词,
Jieba是久经考验的分词库。对于模型部分,有两种主流路径:一是采用传统机器学习方法,如使用sklearn的朴素贝叶斯、支持向量机(SVM)等,结合TF-IDF特征;二是使用预训练的深度学习模型,如Hugging Face的Transformers库中的BERT、RoBERTa等,进行微调。前者速度快、可解释性强,适合入门;后者效果好、更能理解上下文,但需要更多计算资源。
注意:在爬虫环节,务必严格遵守豆瓣的
robots.txt协议,并设置合理的请求间隔(如DOWNLOAD_DELAY = 2秒),避免对目标服务器造成压力,这也是一个负责任的爬虫工程师的基本素养。
2.2 爬虫策略与反爬应对思路
豆瓣网对爬虫有一定的防御措施。直接粗暴请求很容易被识别并限制访问。我们的爬虫策略需要更加“拟人化”和“友好化”。
- 入口与分页:入口是固定的豆瓣电影TOP250页面。爬虫需要先解析这个页面,提取出250部电影的详情页链接。对于每部电影的短评,通常需要处理分页。观察URL规律,短评分页通常通过
start参数控制(如?start=20)。 - 请求头(Headers)定制:这是最基本也是最有效的一步。必须在Scrapy的Request中模拟真实浏览器的Headers,特别是
User-Agent。最好准备一个User-Agent列表,在中间件中随机切换。 - 请求延迟与并发控制:在
settings.py中设置DOWNLOAD_DELAY(如2秒)和CONCURRENT_REQUESTS_PER_DOMAIN(如1),降低请求频率,模拟人类浏览速度。 - IP代理池的考量:对于大规模爬取,IP被封锁是常见问题。可以考虑使用IP代理池。但在本项目数据量下,通过严格遵守延迟规则,通常可以避免触发IP封锁。如果必须使用,可以编写一个下载器中间件来集成代理。
- Cookie的处理:部分内容(如查看更多短评)可能需要登录后的Cookie。我们可以先手动登录豆瓣,在浏览器开发者工具中复制Cookie字符串,在Scrapy Request中携带。但请注意,Cookie会过期,且本项目爬取公开短评通常无需登录。
- 动态内容的应对:豆瓣的短评页面是静态的,但有些网站评论是通过JavaScript动态加载的。如果遇到这种情况,传统的Scrapy无法直接获取。这时就需要用到像
Splash或Scrapy-Playwright这样的工具来渲染页面。从相关热词scrapy playwright 动态 iframe可以看出,这是当前处理动态网页的主流方案。虽然豆瓣短评不需要,但掌握这一技能对爬虫工程师至关重要。
3. Scrapy爬虫核心细节与实操要点
3.1 创建Scrapy项目与爬虫结构
首先,通过命令行创建项目骨架,这是保持代码组织清晰的基础。
scrapy startproject douban_top250 cd douban_top250 scrapy genspider movie_spider movie.douban.com这会产生一个标准的项目结构。我们需要重点关注以下几个文件:
items.py: 定义我们要爬取的数据结构(类似字典的模板)。spiders/movie_spider.py: 爬虫的核心逻辑,包括如何发起请求和解析响应。pipelines.py: 数据清洗和存储的逻辑。middlewares.py: 自定义中间件,用于处理请求和响应,如设置代理、更换User-Agent。settings.py: 项目全局设置,如延迟、并发、启用的组件。
3.2 定义数据模型与解析规则
在items.py中,我们定义一个DoubanCommentItem类,明确要抓取的字段。
import scrapy class DoubanCommentItem(scrapy.Item): # 定义字段,像定义数据库表的列 movie_name = scrapy.Field() # 电影名称 movie_rank = scrapy.Field() # TOP250排名 comment_content = scrapy.Field() # 评论内容 comment_rating = scrapy.Field() # 用户评分(五星制,如“力荐”对应5) comment_time = scrapy.Field() # 评论时间 comment_votes = scrapy.Field() # “有用”数 # 还可以添加用户ID、城市等信息,根据需求定接下来是重头戏,在movie_spider.py中编写爬虫逻辑。核心是parse方法以及可能有的parse_comment方法。
import scrapy from ..items import DoubanCommentItem from urllib.parse import urljoin class MovieSpiderSpider(scrapy.Spider): name = 'movie_spider' allowed_domains = ['movie.douban.com'] start_urls = ['https://movie.douban.com/top250'] def parse(self, response): # 1. 解析TOP250列表页,获取每部电影的链接和排名 movie_elements = response.css('.grid_view .item') for idx, movie in enumerate(movie_elements, start=1): movie_detail_url = movie.css('.hd a::attr(href)').get() movie_name = movie.css('.title::text').get().strip() # 构建请求,传入电影名和排名作为meta,用于在回调函数中传递 yield scrapy.Request( url=movie_detail_url, callback=self.parse_movie_detail, meta={'movie_name': movie_name, 'movie_rank': idx} ) # 2. 处理TOP250列表的分页(如果有) next_page = response.css('.paginator .next a::attr(href)').get() if next_page: next_page_url = urljoin(response.url, next_page) yield scrapy.Request(url=next_page_url, callback=self.parse) def parse_movie_detail(self, response): # 进入电影详情页,寻找短评链接 movie_name = response.meta['movie_name'] movie_rank = response.meta['movie_rank'] # 短评页面的URL模式 comments_url = response.url + 'comments?status=P' # 请求短评页面,并传递电影信息 yield scrapy.Request( url=comments_url, callback=self.parse_comments, meta={'movie_name': movie_name, 'movie_rank': movie_rank}, # 可以在这里添加headers或cookies ) def parse_comments(self, response): movie_name = response.meta['movie_name'] movie_rank = response.meta['movie_rank'] # 解析当前页的短评 comment_elements = response.css('.comment-item') for comment in comment_elements: item = DoubanCommentItem() item['movie_name'] = movie_name item['movie_rank'] = movie_rank # 使用XPath或CSS选择器精确提取内容 item['comment_content'] = comment.css('.short::text').get().strip() # 评分可能为空(用户未评分),需要处理 rating_class = comment.css('.rating::attr(class)').get() if rating_class: # 从'allstar50 rating'这样的字符串中提取数字 item['comment_rating'] = int(rating_class.split('allstar')[1].split(' ')[0]) // 10 else: item['comment_rating'] = None item['comment_time'] = comment.css('.comment-time::text').get().strip() item['comment_votes'] = int(comment.css('.votes::text').get()) yield item # 将提取到的item抛给Pipeline处理 # 处理短评分页 next_page = response.css('#paginator a.next::attr(href)').get() if next_page: next_page_url = urljoin(response.url, next_page) yield scrapy.Request( url=next_page_url, callback=self.parse_comments, meta={'movie_name': movie_name, 'movie_rank': movie_rank} )实操要点:
- 选择器的稳定性:豆瓣的网页结构可能会微调。上述CSS选择器是基于某一时期的页面结构编写的。在实际操作前,务必使用浏览器的开发者工具(F12)重新检查元素,确认选择器是否依然有效。XPath通常更精确,但CSS选择器更易读。
- 异常处理:代码中对于
comment_rating的处理就是一个例子。不是所有评论都有评分,所以需要判断rating_class是否存在,否则会抛出AttributeError。在实际项目中,每个字段的提取都应该包裹在try...except中,或者进行空值判断,确保爬虫的健壮性。 - Meta数据的传递:在多层回调(如从列表页到详情页,再到评论页)时,使用
Request的meta参数传递数据(如电影名)是标准做法。
3.3 配置优化与反爬策略实施
在settings.py中进行关键配置,这是爬虫能否稳定运行的核心。
# 设置全局请求延迟,单位秒。2-3秒是比较友好的间隔。 DOWNLOAD_DELAY = 2.5 # 对同一域名的并发请求数,设置为1可以配合延迟更好地模拟人工。 CONCURRENT_REQUESTS_PER_DOMAIN = 1 # 禁用Cookie(除非需要)。豆瓣查看公开评论通常不需要Cookie,禁用可以减少被追踪的风险。 COOKIES_ENABLED = False # 设置一个看起来像真实浏览器的User-Agent。 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' # 为了更好地模拟,可以启用一个随机User-Agent中间件。 # 首先安装:pip install scrapy-fake-useragent # 然后在settings.py中配置 DOWNLOADER_MIDDLEWARES = { 'scrapy.downloadermiddlewares.useragent.UserAgentMiddleware': None, # 禁用默认 'scrapy_fake_useragent.middleware.RandomUserAgentMiddleware': 400, # 启用随机 } # 启用并配置Item Pipeline,用于数据清洗和存储。 ITEM_PIPELINES = { 'douban_top250.pipelines.DoubanCommentPipeline': 300, } # 遵守robots协议(建议开启) ROBOTSTXT_OBEY = True注意事项:
DOWNLOAD_DELAY的设置需要权衡。太短(如0.1秒)容易被封,太长(如10秒)则效率极低。对于豆瓣,2-5秒是一个安全范围。你可以先设置得长一些,稳定运行后再尝试缩短。- 如果遇到403禁止访问,除了检查
User-Agent,还可以尝试在请求头中添加Referer字段,其值设置为上一个页面的URL。 - 启用
RandomUserAgentMiddleware后,记得在middlewares.py中查看或配置其使用的UA列表,确保其有效性。
4. 数据清理、存储与分析实战
4.1 构建数据清洗管道
爬虫抓取到的原始数据是“脏”的,需要在pipelines.py中进行清洗。清洗逻辑因人而异,但通常包括以下步骤:
import pymysql from itemadapter import ItemAdapter import re class DoubanCommentPipeline: def process_item(self, item, spider): # 1. 去除评论内容中的多余空白字符(包括换行、制表符、连续空格) if item.get('comment_content'): # 替换所有空白字符(包括全角空格)为单个空格,并去除首尾空格 content = item['comment_content'] content = re.sub(r'\s+', ' ', content) # \s匹配任何空白字符 item['comment_content'] = content.strip() # 2. 清洗评论时间,提取标准日期部分 # 原始格式可能为“2023-12-01 20:15”或“2023-12-01” if item.get('comment_time'): time_str = item['comment_time'] # 使用正则匹配日期部分 match = re.search(r'(\d{4}-\d{2}-\d{2})', time_str) if match: item['comment_time'] = match.group(1) else: item['comment_time'] = None # 无法解析则置空 # 3. 处理评分字段,确保是整数或None # 在爬虫解析阶段已经做了处理,这里可以再次校验或转换 if item.get('comment_rating') is not None: try: item['comment_rating'] = int(item['comment_rating']) except ValueError: item['comment_rating'] = None # 4. 处理“有用”数,确保是整数 if item.get('comment_votes'): try: item['comment_votes'] = int(item['comment_votes']) except ValueError: item['comment_votes'] = 0 # 5. (可选)简单的文本过滤,如去除纯符号或过短的无效评论 if item.get('comment_content') and len(item['comment_content'].strip()) < 5: # 如果清洗后内容过短,可以选择丢弃该item # raise DropItem(f"评论过短被丢弃: {item}") pass # 这里我们选择保留,但可以标记 return item清洗之后,我们需要将数据存储起来。这里以存储到MySQL数据库为例。
class MysqlPipeline: def __init__(self, mysql_settings): self.mysql_settings = mysql_settings @classmethod def from_crawler(cls, crawler): # 从settings.py读取数据库配置 return cls( mysql_settings={ 'host': crawler.settings.get('MYSQL_HOST', 'localhost'), 'port': crawler.settings.get('MYSQL_PORT', 3306), 'user': crawler.settings.get('MYSQL_USER', 'root'), 'password': crawler.settings.get('MYSQL_PASSWORD', ''), 'database': crawler.settings.get('MYSQL_DATABASE', 'douban'), 'charset': 'utf8mb4', # 重要!支持存储Emoji等四字节字符 } ) def open_spider(self, spider): # 爬虫启动时连接数据库 self.connection = pymysql.connect(**self.mysql_settings) self.cursor = self.connection.cursor() # 创建表(如果不存在) create_table_sql = """ CREATE TABLE IF NOT EXISTS movie_comments ( id INT AUTO_INCREMENT PRIMARY KEY, movie_name VARCHAR(255) NOT NULL, movie_rank INT, comment_content TEXT, comment_rating INT, comment_time DATE, comment_votes INT, crawl_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; """ self.cursor.execute(create_table_sql) self.connection.commit() def process_item(self, item, spider): # 将item插入数据库 insert_sql = """ INSERT INTO movie_comments (movie_name, movie_rank, comment_content, comment_rating, comment_time, comment_votes) VALUES (%s, %s, %s, %s, %s, %s) """ self.cursor.execute(insert_sql, ( item['movie_name'], item['movie_rank'], item['comment_content'], item['comment_rating'], item['comment_time'], item['comment_votes'] )) self.connection.commit() return item def close_spider(self, spider): # 爬虫关闭时关闭数据库连接 self.cursor.close() self.connection.close()记得在settings.py中配置数据库连接信息,并启用这个Pipeline。
实操心得:
- 字符编码是坑:中文网站和Emoji表情需要使用
utf8mb4字符集,否则存储时会出现乱码或错误。 - 异常处理与日志:数据库操作(连接、插入)必须进行异常处理(
try...except),并记录日志,便于排查是网络问题、SQL语法错误还是数据格式错误。 - 增量爬取:如果计划定期更新数据,需要在数据库设计上考虑。可以为每条评论生成一个唯一标识(如基于用户ID、电影ID、评论时间哈希),并在插入前检查是否已存在,实现去重和增量更新。这比清空重爬要友好得多。
4.2 使用Pandas进行探索性数据分析
数据存入数据库后,我们可以用Python连接数据库,用Pandas加载数据进行分析。
import pandas as pd import pymysql import matplotlib.pyplot as plt import seaborn as sns from wordcloud import WordCloud import jieba # 1. 连接数据库,读取数据 connection = pymysql.connect(host='localhost', user='root', password='your_password', database='douban', charset='utf8mb4') df = pd.read_sql('SELECT * FROM movie_comments', con=connection) connection.close() print(f"数据总览:共 {df.shape[0]} 条评论,{df.shape[1]} 个字段") print(df.info()) # 查看数据类型和缺失值 print(df.head()) # 查看前几行数据 # 2. 数据质量检查与处理 # 检查缺失值 print(df.isnull().sum()) # 处理缺失的评分,可以用中位数填充,或单独标记为“未评分” df['comment_rating'].fillna(0, inplace=True) # 假设0代表未评分 # 3. 基础统计分析 # 评分分布 rating_dist = df['comment_rating'].value_counts().sort_index() plt.figure(figsize=(10,6)) rating_dist.plot(kind='bar') plt.title('豆瓣TOP250电影短评评分分布') plt.xlabel('评分 (星)') plt.ylabel('评论数量') plt.xticks(rotation=0) plt.show() # 评论长度分布 df['content_length'] = df['comment_content'].apply(len) plt.figure(figsize=(10,6)) df['content_length'].hist(bins=50, edgecolor='black') plt.title('评论内容长度分布') plt.xlabel('评论长度 (字符数)') plt.ylabel('频次') plt.show() print(f"平均评论长度:{df['content_length'].mean():.2f} 字符") # 电影维度分析:哪部电影评论最多? movie_comment_count = df['movie_name'].value_counts().head(10) plt.figure(figsize=(12,6)) movie_comment_count.plot(kind='barh') # 水平条形图更易读 plt.title('评论数量最多的前十部电影') plt.xlabel('评论数量') plt.show() # 4. 文本数据初步探索:生成词云 # 将所有评论拼接成一个长文本 all_comments = ' '.join(df['comment_content'].dropna().astype(str).tolist()) # 使用jieba分词 words = ' '.join(jieba.lcut(all_comments)) # 生成词云,排除一些无意义的停用词(需要准备停用词表) stopwords = set(['电影', '一部', '这个', '没有', '什么', '就是', '还是', '可以', '因为', '所以']) wordcloud = WordCloud(font_path='simhei.ttf', width=800, height=600, background_color='white', stopwords=stopwords).generate(words) plt.figure(figsize=(12,8)) plt.imshow(wordcloud, interpolation='bilinear') plt.axis('off') plt.title('豆瓣TOP250短评词云') plt.show()通过以上分析,我们可以快速得到一些直观结论:例如,评分分布是否呈两极分化(很多人打5星或1星)?评论平均长度是多少?哪些电影的讨论热度最高?词云中出现了哪些高频词汇(如“经典”、“剧情”、“演技”、“感动”)?这些都为后续的情感分析提供了背景和假设。
5. 构建中文文本情感分析模型
情感分析是NLP的经典任务。对于中文,我们有两种主流路径:基于情感词典/规则的传统方法,以及基于机器学习/深度学习的方法。前者速度快但精度有限,后者精度高但需要训练数据。这里我们重点介绍更通用、效果更好的机器学习方法。
5.1 数据准备与标注
机器学习模型需要带标签的数据。豆瓣短评自带用户评分(1-5星),这天然构成了一个情感标签!我们可以将评分映射为情感类别:
- 正面 (Positive): 4星或5星
- 负面 (Negative): 1星或2星
- 中性 (Neutral): 3星
这是一个有监督学习任务。我们首先需要根据这个规则为每条评论打上标签。
# 根据评分定义情感标签 def rating_to_sentiment(rating): if rating >= 4: return 'positive' elif rating <= 2: return 'negative' else: return 'neutral' df['sentiment'] = df['comment_rating'].apply(rating_to_sentiment) print(df['sentiment'].value_counts())注意:这种映射并非绝对准确。一个用户打了3星,其评论内容可能是中性的,也可能是褒贬参半的。但在缺乏人工标注的情况下,利用评分作为代理标签是实践中非常常见的做法,且通常能获得不错的效果。
5.2 特征工程:从文本到向量
计算机无法直接理解文本,我们需要将文本评论转换为数值向量(特征)。最经典的方法是TF-IDF。
- 分词:首先使用
jieba对每条评论进行分词。 - 去除停用词:去除“的”、“了”、“在”等对情感判断无意义的常用词。
- TF-IDF向量化:计算每个词在文档中的重要性,并将其转换为一个高维稀疏向量。
from sklearn.feature_extraction.text import TfidfVectorizer import jieba # 准备停用词列表(可以从网上下载或自己维护一个) def load_stopwords(filepath): with open(filepath, 'r', encoding='utf-8') as f: stopwords = [line.strip() for line in f] return set(stopwords) stopwords = load_stopwords('chinese_stopwords.txt') # 定义分词函数 def chinese_cut(text): # 对文本进行分词,并过滤停用词 words = jieba.lcut(str(text)) return ' '.join([w for w in words if w not in stopwords and w.strip() != '']) # 应用分词 df['comment_cut'] = df['comment_content'].apply(chinese_cut) # 划分训练集和测试集 from sklearn.model_selection import train_test_split X = df['comment_cut'] y = df['sentiment'] X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42, stratify=y) # 使用TF-IDF进行特征提取 vectorizer = TfidfVectorizer(max_features=5000) # 限制最大特征数,防止维度爆炸 X_train_tfidf = vectorizer.fit_transform(X_train) X_test_tfidf = vectorizer.transform(X_test) print(f"特征矩阵形状:{X_train_tfidf.shape}")5.3 模型训练、评估与优化
有了特征向量和标签,我们就可以训练分类模型了。这里以逻辑回归和朴素贝叶斯为例,它们在小规模文本分类上往往有不错的表现。
from sklearn.linear_model import LogisticRegression from sklearn.naive_bayes import MultinomialNB from sklearn.metrics import classification_report, confusion_matrix, accuracy_score import numpy as np # 训练逻辑回归模型 lr_model = LogisticRegression(max_iter=1000, random_state=42) lr_model.fit(X_train_tfidf, y_train) y_pred_lr = lr_model.predict(X_test_tfidf) print("=== 逻辑回归模型性能 ===") print(f"准确率:{accuracy_score(y_test, y_pred_lr):.4f}") print(classification_report(y_test, y_pred_lr)) # 训练朴素贝叶斯模型(特别适合文本分类) nb_model = MultinomialNB() nb_model.fit(X_train_tfidf, y_train) y_pred_nb = nb_model.predict(X_test_tfidf) print("\n=== 朴素贝叶斯模型性能 ===") print(f"准确率:{accuracy_score(y_test, y_pred_nb):.4f}") print(classification_report(y_test, y_pred_nb)) # 可以查看哪些词对判断正面/负面情感贡献最大(模型可解释性) feature_names = vectorizer.get_feature_names_out() for i, class_label in enumerate(lr_model.classes_): # 获取该类别的权重系数,排序 top10 = np.argsort(lr_model.coef_[i])[-10:] # 取权重最高的10个特征索引 print(f"\n对于类别 '{class_label}',最重要的10个词是:") print([feature_names[j] for j in top10])模型选择与调优:
- 传统模型:逻辑回归、朴素贝叶斯、支持向量机(SVM)都是不错的选择。可以尝试用
GridSearchCV搜索最佳的超参数(如逻辑回归的C值,SVM的核函数)。 - 深度学习模型:如果追求更高的准确率,可以考虑使用预训练的中文BERT模型(如
bert-base-chinese)进行微调。这需要安装transformers库,并且训练时间会显著增加,但对上下文的理解能力更强。 - 类别不平衡:检查
y_train的分布。如果正面、负面、中性评论数量差异巨大,需要考虑在模型训练时使用class_weight='balanced'参数,或者对少数类进行上采样。
5.4 模型部署与应用
训练好模型后,我们可以将其保存下来,用于对新评论进行情感预测。
import joblib import pickle # 保存模型和向量化器 joblib.dump(lr_model, 'sentiment_lr_model.pkl') # 向量化器也需要保存,因为新文本需要用同样的方式转换 with open('tfidf_vectorizer.pkl', 'wb') as f: pickle.dump(vectorizer, f) # 加载模型进行预测 loaded_model = joblib.load('sentiment_lr_model.pkl') loaded_vectorizer = pickle.load(open('tfidf_vectorizer.pkl', 'rb')) def predict_sentiment(text): # 对新文本进行同样的预处理和向量化 processed_text = chinese_cut(text) text_vector = loaded_vectorizer.transform([processed_text]) prediction = loaded_model.predict(text_vector)[0] # 可以同时获取预测概率 proba = loaded_model.predict_proba(text_vector)[0] return prediction, dict(zip(loaded_model.classes_, proba)) # 测试 new_comment = "这部电影的剧情太拖沓了,看得我昏昏欲睡。" sentiment, prob_dict = predict_sentiment(new_comment) print(f"评论:'{new_comment}'") print(f"预测情感:{sentiment}") print(f"各类别概率:{prob_dict}")至此,我们完成了一个从数据采集、清洗、分析到建模预测的完整闭环。你可以将这个流程封装成一个简单的Web服务或API,实现实时对输入文本进行情感判断的功能。
6. 常见问题与排查技巧实录
在实际操作这个项目的过程中,你几乎一定会遇到下面这些问题。这里记录了我踩过的坑和解决方法,希望能帮你节省时间。
6.1 爬虫环节常见问题
问题1:爬虫运行后很快被屏蔽,返回403或验证码页面。
- 排查:首先检查
User-Agent是否设置且模拟得像真实浏览器。其次,检查请求频率是否过高。 - 解决:
- 降低频率:增加
DOWNLOAD_DELAY到3-5秒,并将CONCURRENT_REQUESTS_PER_DOMAIN设为1。 - 丰富Headers:除了
User-Agent,添加Referer(通常设置为豆瓣域名首页)、Accept-Language等。 - 使用代理IP:如果单个IP被封锁,这是终极方案。可以购买付费代理服务,或搭建自己的代理池。在Scrapy中通过下载器中间件设置
request.meta['proxy']。 - 模拟登录:如果非登录状态只能看到少量评论,可能需要处理登录。使用
scrapy.FormRequest模拟POST登录,并管理好Session Cookie。
- 降低频率:增加
问题2:解析不到数据,XPath/CSS选择器返回空列表。
- 排查:网页结构可能已更新。打开浏览器开发者工具,重新检查目标元素的选择器。确认爬虫请求的页面是否包含了预期的数据(有时数据可能在后续的AJAX请求中)。
- 解决:
- 更新选择器:使用浏览器开发者工具的“Copy selector”或“Copy XPath”功能获取最新路径,但要注意其可能过于脆弱。尽量使用基于
class或id的相对稳定的选择器。 - 检查响应内容:在爬虫的
parse函数中打印response.text[:1000],看看是否真的获取到了包含目标数据的HTML。如果没有,可能是触发了反爬,返回了不同的页面。 - 处理动态加载:如果数据是JS动态加载的,需要借助
Splash或Scrapy-Playwright。安装scrapy-playwright后,在Request中使用scrapy.Request(url, callback, meta={'playwright': True})。
- 更新选择器:使用浏览器开发者工具的“Copy selector”或“Copy XPath”功能获取最新路径,但要注意其可能过于脆弱。尽量使用基于
问题3:数据存入数据库时出现乱码或报错“Incorrect string value”。
- 排查:数据库、表和连接字符集设置不正确,无法存储Emoji或某些特殊中文字符。
- 解决:
- 确保数据库、表、字段的字符集均为
utf8mb4。创建连接时指定charset='utf8mb4'。 - 在Python连接MySQL时,设置
pymysql.connect(charset='utf8mb4')。 - 对于其他数据库(如SQLite),也需要确保使用正确的编码。
- 确保数据库、表、字段的字符集均为
6.2 数据分析与建模环节常见问题
问题1:情感分析模型准确率很低(比如低于60%)。
- 排查:
- 数据质量问题:标签(评分映射的情感)噪声太大。很多3星评论内容可能是负面的。
- 特征工程问题:TF-IDF特征维度不够或太多,没有进行有效的特征选择。停用词表不准确,过滤掉了有情感色彩的词。
- 类别不平衡:某个情感类别的样本数远少于其他类别。
- 解决:
- 数据清洗:考虑过滤掉过短(如少于3个词)或内容无意义的评论。可以尝试只使用极端评分(1-2星和4-5星)的数据做二分类,准确率通常会提升。
- 优化特征:调整
TfidfVectorizer的参数,如max_df(忽略出现频率太高的词)、min_df(忽略出现频率太低的词)、ngram_range(尝试加入二元词组(1,2))。使用sklearn.feature_selection.SelectKBest选择最重要的K个特征。 - 处理不平衡:使用
from imblearn.over_sampling import SMOTE进行过采样,或在模型中使用class_weight='balanced'。 - 尝试其他模型:SVM、随机森林、或者简单的深度学习模型如TextCNN、LSTM。
- 使用预训练模型:直接微调
bert-base-chinese,这是提升精度最有效的方法,但需要GPU资源。
问题2:分词效果不理想,将专有名词(如电影名、演员名)切分。
- 排查:
jieba的默认词典可能不包含这些新词或领域词。 - 解决:
- 加载自定义词典:将电影名、常见演员名等整理成一个文本文件(每行一个词),使用
jieba.load_userdict('my_dict.txt')加载。 - 调整分词模式:对于短文本,使用
jieba.lcut_for_search可能比jieba.lcut更细粒度,但也不一定。需要根据效果测试。
- 加载自定义词典:将电影名、常见演员名等整理成一个文本文件(每行一个词),使用
问题3:词云图显示的都是“电影”、“好看”这类通用词,没有区分度。
- 排查:停用词表不完善,没有过滤掉这些高频但无分析价值的词。
- 解决:维护一个更全面的中文停用词表。可以在开源的中文停用词表(如
hit_stopwords、baidu_stopwords)基础上,根据你的分析目标(这里是电影评论)手动添加一些领域内的高频无意义词,如“电影”、“一部”、“这个”、“觉得”等。
这个项目麻雀虽小,五脏俱全。每一次复现,你都可能因为网站改版、库版本更新或对问题有了新的理解而遇到不同的挑战。解决问题的过程,正是能力提升最快的时候。建议你在完成基础流程后,尝试一些扩展,比如用Scrapy-Redis实现分布式爬取,用PySpark处理更大的数据集,或者尝试更复杂的深度学习模型,让这个项目成为你数据技能树上的一块坚实基石。
本文还有配套的精品资源,点击获取