☰
豆瓣电影10万数据集:从清洗到推荐系统的完整实战
2026/10/17 19:50:22 网站建设 项目流程

简介:十万条规模的豆瓣电影数据包面向数据分析爱好者、推荐系统开发者及影视市场研究者,可支撑用户行为分析、个性化推荐、情感挖掘等多种场景。资源共4个文件,含两个CSV表格、一个SQLite数据库和一个爬虫脚本,压缩后约40MB;表格分别记录用户与电影基础数据,涵盖编号、片名、导演、类型、评分等关键字段,数据库则包含观影记录、影评和互动信息,便于深度查询,脚本演示了抓取清洗数据的完整思路。目前已有3442人学习。借助这套数据,既可构建基于用户评分与电影属性的推荐系统,也能分析不同影片类型的受欢迎程度与口碑走向,还能通过爬虫代码理解从采集、解析到存储的工程化方法。数据真实来源于豆瓣,对学术研究与商业决策均有参考价值。

1. 这个压缩包里装了什么:十万部电影信息和它的真实用途

拿到“豆瓣电影数据集10万.zip”这个文件名,第一反应很容易是“不就是10万行电影列表”,实际上它对做中文推荐系统的人来说是相当好的一手素材:包含电影基础信息、导演主演、类型标签、评分及评分人数,还有年份和地区,既能训练内容推荐模型,也能做统计分析。自己从零去抓豆瓣公开页面,要面对登录验证、反爬限制、IP封禁这一串麻烦,公开数据集的价值就是先把采集环节省掉,把时间留给清洗、特征工程和算法实验。这份数据适合两类人:练手推荐系统的开发者,以及准备做电影分析类产品、需要快速拿到结构化样本的从业者。当然它也保留了豆瓣数据的固有脾气——字段缺失、版本混杂、编码陷阱,后面几章专门讲怎么处理。

2. 拆开压缩包先看骨头:文件结构与核心字段取舍

先纠正一个很多人会踩的误判:拿到“豆瓣电影数据集10万.zip”后直接解压看CSV,不先看字段说明。这个数据集在社区里有多个流传版本,常见结构是一张主表加可选的评论表,主表每行是一部电影;字段命名每个版本不一样,比如类型字段有叫“genres”也有叫“type”,主演字段有叫“casts”也有叫“actors”。先把字段对应关系捋清,后面所有清洗代码才不会反复改。

2.1 主表字段速查:先分清展示类与特征类

解开压缩包后,核心文件通常是一张逗号分隔的电影主表,有时还带一个字段说明文档。我的习惯是先把字段分成“展示类”和“特征类”:展示类如片名、详情页URL,主要用于前台页面展示;特征类如类型、主演、导演,用于算法和筛选。下面这张速查表覆盖了最常见主表结构,你拿到的版本可能缺一两列或多一两列,但核心字段基本在其中。

字段常见命名类型缺失风险在项目里的用途
电影名name / titlestr低展示、检索、去重
评分rating / scorefloat中排序、推荐加权
评分人数votes / rating_peopleint中热度加权、过滤噪声
导演directorstr低内容特征、作者偏好
主演casts / actorsstr中内容特征
年份year / pubdateint / str中时间衰减、趋势分析
类型genres / typestr低推荐核心特征
地区regions / countrystr中地区过滤、用户画像
语言languagestr高辅助过滤,可缺失
详情页URLurl / altstr低跳转展示或补充采集

这里要重点记住一行主表是“一部电影一行”,不是“一条评分一行”。也就是说,如果没有带用户ID的评分子表,基于用户的协同过滤在主表上做不了;你能用的只是电影侧的结构化信息。另一个容易看错的列是“评分人数”,它不只是统计字段,而是评分的可信度标尺。一部只有两个人打分的小众片拿了9.8分,和一部两万人评的8.5分电影相比,后者的口碑参考价值明显更高,所以后面所有榜单类逻辑都得把评分人数作为权重带进去。

2.2 三类字段决定项目下限:评分、类型、年份

第一类是评分和评分人数。这两个字段最直接的用法是排“评分热榜”,但直接按评分排序会翻车,因为冷门片样本小、方差大,几个5星就能把均值顶上去。我一般会做热度加权,用贝叶斯平均的思路给评分打折扣:

# 用评分人数做平滑系数,避免小样本高分霸榜 C = 100.0 # 平滑常数,决定“多少票才算可信” mean_rating = df["rating"].mean() df["weighted_rating"] = ( (df["votes"] / (df["votes"] + C)) * df["rating"] + (C / (df["votes"] + C)) * mean_rating )

逻辑说明:这个公式把每部电影的评分往全局均值方向拉。评分人数很少时,votes / (votes + C)接近0,加权分逼近全局均值;评分人数很大时,权重接近1,加权分接近原始评分。C值取多少要看数据分布,10万行规模取50到200之间都合理,C越大对冷门片越严厉。

第二类是类型和主演。类型字段通常是用分隔符把多个值拼在一格里存放,一部电影同时属于“剧情”“爱情”“历史”三个标签很正常。这种结构不能直接进分类模型,必须先拆。主演字段更麻烦,部分版本会把角色名用括号写在人名后面,比如“某演员(饰某角色)”,正则清洗要放在拆分之前,顺序反了后面的One-Hot编码会多出一堆无意义特征。实测中还发现,类型字段有时存在空字符串而不是NaN,空字符串在 if 判断里是 True,容易混过缺失值检查,需要先替换成NaN。

第三类是年份和地区。年份适合做时间维度分析,比如看哪些年份高分电影密度最高,也可以给推荐结果加时间衰减。但年份字段本身可能混入“2001-01-01”这种完整日期格式,直接pd.to_numeric会报错或读成NaN;地区字段则是多个地区挤在一格里,要像类型一样拆分。这两个字段单独作为特征的效果一般,但和类型组合后,对相似度计算的区分度有明显提升。

最后说一句字段优先级。如果目标是推荐系统,优先级是“类型 > 评分人数 > 主演 > 导演 > 年份 > 地区 > 语言”;如果目标是统计可视化,优先级变成“年份 > 评分 > 评分人数 > 类型 > 地区”。目标不同,前面清洗脚本里保留字段的策略也会不同,所以拿到数据先别急着动手,先明确项目输出是什么。

3. 读进内存只是开始:编码、脏数据和列表字段的清洗

读取一个10万行的CSV在技术上没有难度,真正的难点是编码和字段污染。数据集是从豆瓣不同来源导出的,工具链不同,文件编码可能是UTF-8也可能是GBK。不要赌编码,先用探测库确认,再把结果交给pandas。

3.1 最小读取方案一次跑通

先做编码探测,再读取主表。这段代码直接抄了能跑。

import pandas as pd from charset_normalizer import from_path # 第一步:探测文件真实编码,避免手动试错 result = from_path("douban_movies.csv").best() print("encoding:", result.encoding) # 第二步:按探测结果读取主表 df = pd.read_csv( "douban_movies.csv", encoding=result.encoding, # 常见结果是 utf-8 或 gb18030 sep=",", # 如果解压出来是tsv格式,改成 sep="\t" dtype={"year": str}, # 年份先按字符串读,防止被读成float low_memory=False, # 10万行级别没有性能压力,关掉分块避免类型误判 ) print(df.shape) print(df.columns.tolist())

逻辑说明:charset_normalizer是比 chardet 更新一点的编码探测库,from_path会采样文件头部给出候选编码,best()取置信度最高的结果。把探测结果直接传给encoding,在绝大多数情况下能得到 utf-8 或 gb18030。dtype={"year": str}这行很多人会忽略,但如果年份列里混入完整日期字符串,pandas 可能自动推断成 datetime,后面做年份过滤反而多一层转换。low_memory=False在10万行规模不会拖慢速度,但能避免 pandas 因为分块读取而把某列类型推断成 object,这是一个很隐蔽的坑。

读取之后不要急着清洗,先做一轮体检,输出缺失占比、重复行数和几个真实样例:

# 体检输出:缺失占比、整行重复数、类型和主演字段的真实形态 print("缺失占比: ") print(df.isnull().mean().round(3)) print("整行重复: ", df.duplicated().sum()) print("类型样例: ", df["genres"].dropna().head(3).to_list()) print("主演样例: ", df["casts"].dropna().head(3).to_list())

参数说明:isnull().mean()是计算缺失率的最短写法,布尔值True被当1、False被当0,均值就是缺失比例,越接近1说明字段越可有可无。duplicated()默认要求整行所有字段完全相等才算重复,这一步只能查出“完全一样”的垃圾行;片名相同但导演不同的“变体重复”,必须用 subset 参数单独判断。to_list()把Series转成列表打印是故意为之,直接跑head()在终端里看到的是带索引的列,不如列表直观。

3.2 三类脏数据的处理原则

看完体检输出后,把脏数据分三类处理。第一类是整字段缺失。语言字段缺失率最高,地区次之。缺失不等于错误,如果字段不影响推荐逻辑,保留NaN或填“未知”即可;但类型这种核心特征一旦缺失,这一行基本不能再参与特征计算,应在构造特征矩阵前整行过滤,否则 One-Hot 出来的就是全零向量。

第二类是格式混用。最典型的是类型和主演字段的分隔符不统一:一部分行用管道符,一部分用斜杠,还有一部分用中文逗号。这是多来源数据合并的后遗症。处理顺序必须是“先统一,后拆分”,不能边拆边替换,否则会漏掉一批行。还有一个容易被忽略的细节是演员名里可能带空格,比如“张 某”,拆分后要记得 strip 首尾空白。

第三类是假正常数据。比如某行评分、评分人数都有,但类型字段是空字符串而不是NaN;再比如年份写成“未知”。空字符串和NaN在pandas里的处理路径完全不同,统一用replace把空字符串转成NaN再做缺失统计,否则缺失值报表会漏掉一批坏样本。

3.3 列表型字段拆分:统一分隔符与去角色名

这里给出通用的字段规整函数,兼容多种分隔符和带角色名的数据。

import re import pandas as pd def normalize_genres(value): """类型字段:统一分隔符后拆分,去掉空白项""" if pd.isna(value): return [] text = str(value) text = re.sub(r"[|/,,]", "|", text) # 管道符、斜杠、中英文逗号全换成 | items = [x.strip() for x in text.split("|") if x.strip()] return items def normalize_cast(value): """主演字段:先去角色名括号,再统一分隔符拆分""" if pd.isna(value): return [] text = str(value) text = re.sub(r"(.*?)|\(.*?\)", "", text) # 去掉(饰某角色) text = re.sub(r"[|/,,]", "|", text) items = [x.strip() for x in text.split("|") if x.strip()] return items df["genre_list"] = df["genres"].apply(normalize_genres) df["cast_list"] = df["casts"].apply(normalize_cast) # 检查拆分效果 print(df[["name", "genres", "genre_list"]].head(3).to_dict("records"))

逻辑说明:正则里的[|/,,]是字符集,匹配四种分隔符中的任意一种,统一替换成管道符之后,split就只需要处理一种情况。主演里去括号的正则写得比较保守,中英文括号都覆盖,因为原始数据里常见“演员名(饰角色)”这种写法。apply而不是for循环,10万行用apply大约一两秒,用循环写可能要几十秒,差别不大,但apply返回的列表列可以直接喂给后面的 MultiLabelBinarizer。注意函数最前面的pd.isna(value)判断必须保留,它同时拦截NaN和None,防止后面str(value)把NaN变成字符串“nan”混进列表。

做完这步,数据里就多了genre_list和cast_list两个列表列,它们直接对接后面的特征矩阵构建。到这里清洗工作完成了一半,剩下的就是按需求做特征工程。

4. 十万条数据怎么产出推荐:内容召回与相似度两个方案

清洗的最终目的不是得到一个干净表格,而是让它参与推荐计算。这里介绍两个不依赖用户ID的方案,分别对应冷启动场景和相似物品召回场景。

4.1 冷启动内容召回:从类型标签生成候选集

新用户没有历史行为,协同过滤直接失效,此时用类型标签做召回是最简单实际的做法。用户勾选几个感兴趣的标签,系统返回命中数量最多的电影,再按热度加权评分排序。

from sklearn.preprocessing import MultiLabelBinarizer # 用清洗好的 genre_list 构建稀疏矩阵 mlb = MultiLabelBinarizer() genre_matrix = mlb.fit_transform(df["genre_list"]) print("genre matrix shape:", genre_matrix.shape) print("genre classes:", mlb.classes_) # 模拟用户选择“剧情”和“悬疑”两个标签 user_tags = ["剧情", "悬疑"] user_vector = [1 if g in user_tags else 0 for g in mlb.classes_] # 矩阵乘法:算出每部电影命中用户标签的数量 hit_count = genre_matrix @ user_vector df["tag_hit"] = hit_count # 候选集:命中>0 且热度足够,再按评分排序 min_votes = df["votes"].median() # 热度门槛用中位数 candidates = df[(df["tag_hit"] > 0) & (df["votes"] >= min_votes)] result = candidates.sort_values(["tag_hit", "rating"], ascending=[False, False]) print(result.head(10)[["name", "rating", "votes", "genres", "tag_hit"]])

逻辑说明:MultiLabelBinarizer接收的是“列表的列表”,输出二维0/1矩阵,行对应每部电影,列对应所有出现过的类型。user_vector是手工构造的0/1向量,维度必须和genre_matrix列数一致,所以用mlb.classes_的顺序去生成,不会错位。矩阵乘法的结果是每个电影命中标签的数量,整个计算没有循环,10万行一瞬间完成。真正值得调的是min_votes这个门槛,它防止低热度电影靠一两票高分排在前面;中位数只是一个起点,按业务场景改成 0.75 分位数或者固定数字都行。

4.2 物品相似度:没有评分矩阵也能做推荐

很多版本的豆瓣数据集只有电影主表,没有“用户-电影-评分”这种三列结构,矩阵分解类的协同过滤做不了。但基于内容的“物品相似度”依然可以用,核心思路是把每部电影转化为特征向量,然后计算向量之间的余弦相似度。

import numpy as np from sklearn.metrics.pairwise import cosine_similarity # 类型和主演都做One-Hot,年份做归一化 cast_mlb = MultiLabelBinarizer() cast_matrix = cast_mlb.fit_transform(df["cast_list"]) years = pd.to_numeric(df["year"], errors="coerce").fillna(0) year_norm = (years - years.min()) / (years.max() - years.min() + 1e-6) # 横向拼接成完整特征矩阵 features = np.hstack([ genre_matrix, cast_matrix, year_norm.values.reshape(-1, 1), ]) print("feature matrix shape:", features.shape) # 取索引0作为锚点,找Top10相似电影 anchor_vec = features[0].reshape(1, -1) sims = cosine_similarity(anchor_vec, features).flatten() top_idx = sims.argsort()[::-1][:11] for i in top_idx: print(df.iloc[i]["name"], round(sims[i], 4))

逻辑说明:把类型矩阵和主演矩阵拼接后,特征维度会明显变大,通常在几千维,但绝大多数位置是0,cosine_similarity对稀疏矩阵做了优化,内存不会爆。拼接顺序可以自己定,但每次跑不同的锚点电影,相似度结果受拼接顺序的影响很小。坑主要在主演列:如果第3章的角色名括号没清干净,同一演员可能被拆成两个特征,相似度会被稀释,导致找出来的“相似电影”明显跑偏。

4.3 推荐算法选型的边界条件

这里需要有个清醒判断:内容推荐什么时候够用,什么时候必须上协同过滤。如果目标是做一个可展示的Demo,内容推荐基本够用,类型命中加评分排序,结果直观而且解释成本低。如果目标是做带个性化的产品级推荐,必须有用户行为数据,光靠电影主表做不了。数据集如果附带评论文本子表,可以从评论里抽词向量,做基于文本的相似推荐,这条路比评分矩阵更稳,因为文本不需要用户ID对齐。

另一个常见误区是直接用评分列做“物品相似度”,比如统计两部电影所有评分的皮尔逊相关。前提是你得先有一张“用户-电影评分”宽表,而且评分极其稀疏时相关系数会失真。常见做法是先过滤,只对“共同评分人数超过50”的电影对计算相似度,否则结果基本是噪声。

5. 避坑手册:把真实踩过的坑按现象整理给你

下面是处理这个数据集时最容易翻车的四个问题,每个都按“现象、原因、解决”记录,方便直接对号入座。

5.1 现象:CSV读进来全是乱码

现象:文件读进来,电影名和类型全是乱码,或者读取直接报UnicodeDecodeError。有的版本前100行正常,后半文件突然出现乱码,这种更隐蔽。

原因:文件实际编码不是UTF-8,而是GBK或GB18030。数据集多半是用国内Windows环境下的工具导出的,默认编码就是GBK。直接指定encoding="utf-8"时,解码器遇到不兼容的字节就会报错,或者解出错误字符;前半段正常是因为文件开头碰巧是纯ASCII字段名,后半段中文一多就露馅。

解决:不要赌编码,先探测再读取,代码见第3章的charset_normalizer用法。或者直接试gb18030,它向下兼容GBK和GB2312。还有一个经验:用记事本打开正常不代表pandas该用UTF-8,记事本有自动编码检测,pandas没有。

5.2 现象:同一部电影出现多行

现象:按片名去重时发现同一部电影出现两行甚至三行,年份相同但评分人数不同,有的版本连导演都一样。

原因:不同时间点导出的数据被合并过,同一个片名在数据库里有多个条目;部分版本还会把“中国大陆版”和“中国香港版”拆成两行。此时按主键ID去重不可靠,因为不同导出版本里ID可能不一样。

解决:用“片名+导演+年份”三字段组合去重,保留评分人数最多的那一行。如果在意的是一版和二版的差别,就先加“地区”字段参与判断,两种版本地区不同,不应该合并。去重时用subset参数指定列:

# 组合去重:片名+导演+年份都相同才算重复,保留评分人数最多的行 df = df.sort_values("votes", ascending=False) df = df.drop_duplicates(subset=["name", "director", "year"], keep="first")

参数说明:先按votes降序排序,再用drop_duplicates的keep="first",天然留下票数最多的行。如果不排序,保留哪一行是不确定的,结果不可复现。

5.3 现象:评分最高分不等于最好片

现象:按评分排Top100,结果前20全是没有听过的小众独立片,评分9.5以上,但评分人数只有几十人。

原因:样本量太小,评分均值方差巨大。几个5星直接把分数拉到9.8,这不代表大众口碑。

解决:加热度门槛或贝叶斯加权。最简单的做法是第2章的weighted_rating,或者直接先过滤再排序:

# 过滤评分人数低于中位数的电影,再按评分排 threshold = df["votes"].median() top_movies = df[df["votes"] >= threshold].sort_values("rating", ascending=False) print(top_movies.head(20)[["name", "rating", "votes"]])

门槛用中位数而不是固定数字,是为了避免换了一个导出版本后阈值失效。如果你想在“过滤”和“加权”之间找一个折中,加权分更平滑,不会在门槛处出现断层。

5.4 现象:类型字段汇总统计翻车

现象:用value_counts统计类型分布,发现“剧情,爱情”和“剧情/爱情”被统计成两个类型,实际是一种电影被拆到了两个格子。

原因:类型字段分隔符不统一,有的行用逗号,有的行用斜杠,有的用管道符。不统一分隔符就拆分,等价于把多标签当成一整串字符串去统计,结果类型数量明显膨胀。

解决:用第3章的normalize_genres先归一化再拆,拆完用explode展开后统计:

# 用 explode 把列表字段展开成一行一值,再统计频率 df_exploded = df.explode("genre_list") genre_stats = df_exploded["genre_list"].value_counts() print(genre_stats.head(15))

逻辑说明:explode是pandas处理列表型字段的标准操作,会把一个“类型列表”纵向展开成多行,每行保留原电影的其他字段。这样value_counts统计的就是真正的类型频率,而不是拼接字符串的频率。展开后的表行数会明显增加,比如10万部电影平均每个3个类型,展开后就是30万行,这个尺寸在内存里依然很轻,但如果后续还要关联计算,记得及时释放中间变量。

6. 让数据集真正跑起来:一个能验证的离线Demo

数据清洗和推荐方案都验证过后,剩下两件事:把方案变成一个能对外演示的接口,以及验证推荐效果不是随机的。我的建议是一开始别上重型框架,用一个小函数加一个简单的接口就能把链路跑通。

6.1 最小接口雏形:两步调用

把前面两套推荐逻辑封装成一个函数,输入用户标签,输出推荐列表,这样后续接Flask或FastAPI都只需要加一层路由,不需要改动算法核心。

def recommend_by_tags(user_tags, top_n=20): """输入标签列表,返回推荐电影DataFrame""" user_vector = [1 if g in user_tags else 0 for g in mlb.classes_] hit = genre_matrix @ user_vector temp = df.copy() temp["hit"] = hit temp = temp[(temp["hit"] > 0) & (temp["votes"] >= df["votes"].median())] temp = temp.sort_values(["hit", "rating"], ascending=[False, False]) return temp.head(top_n) print(recommend_by_tags(["剧情", "悬疑"], top_n=10)[["name", "rating", "votes", "genres"]])

参数说明:user_tags里的标签必须能在mlb.classes_里找到,否则用户向量全为0,结果空列表。接口层应当先做一次“标签是否存在”的校验,返回可读的提示而不是一个空的DataFrame。

6.2 离线稳定性验证:改标签看结果变化

上线前最值得做的一个验证是“输入扰动测试”。随机改用户标签,观察推荐结果变化是否合理。如果换了一个标签后推荐列表几乎不变,说明特征区分度不足,大概率是类型清洗不彻底或者热度门槛把候选集压得太窄。

import random # 随机抽取标签做稳定性检查 sample_tags = random.sample(mlb.classes_.tolist(), 5) for tag in sample_tags: rec = recommend_by_tags([tag], top_n=5) print(tag, "->", rec["name"].tolist())

这个测试看起来简单,实际很有用:如果某个标签下推荐列表为空,那就是该类型标签在清洗后的数据里缺失严重,需要回第3章检查genre_list里是不是混入大量无关字符串。如果每个标签都返回相同的前几名,那就是热度过滤设置过严,或者类型字段取数本身有问题。

我的习惯是把这套测试写成脚本,每次清洗脚本改动后跑一遍,确认改动没有破坏推荐链路。这个数据集第一次跑的时候我被编码和重复数据卡了两天,后来发现这些恰恰是中文数据集里最值得学的东西。希望帮到你。

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

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

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

立即咨询