简介:这份资源面向计算机、人工智能、大数据、数学及电子信息等相关专业的学生与技术学习者,提供一套基于Python与机器学习的豆瓣电影数据分析完整项目,可用于课程设计、期末大作业或毕业设计参考。压缩包共16个文件,约28.18MB,包含py源码、html页面、pdf实验报告、md说明文档,以及txt数据文件、jpg可视化图表和rar评论数据等,覆盖从数据清洗、特征处理到模型训练与结果展示的完整流程。资源内代码经过调试,下载后可直接运行,配合实验报告与词云、评分分布等图表,便于理解机器学习在影评分析中的实际应用。目前已有351人学习下载,适合具备一定Python基础、希望快速搭建数据分析项目并撰写报告的学习者参考使用。
1. 从一份豆瓣电影数据说起:Python 加机器学习到底能挖出什么
很多人第一次拿到「基于python与机器学习的豆瓣电影数据分析源码+报告.zip」这类资源,第一反应是解压、跑通、看报告截图,然后就没有然后了。真正卡住的地方从来不是代码能不能跑,而是你根本不知道这份数据能回答什么问题。豆瓣电影数据里藏着评分分布、类型标签、导演演员、上映年份、评价人数这些字段,单看某一列没意义,把它们串起来才能回答「什么样的电影更容易拿到高分」「评分人数和评分之间是不是线性关系」「类型组合对口碑有没有影响」这类问题。Python 负责把脏数据洗成能算的表格,机器学习负责在特征和标签之间找规律,两者合起来才是一套完整的数据分析链路。这篇内容面向的是想拿一个真实数据集练手、又不想停在「调包跑通」层面的从业者和学生,我会把从数据清洗到模型验证的每一步拆开讲,参数怎么设、哪里容易翻车、报告里的结论该怎么验证,都落到能复现的操作上。
2. 数据拿到手先别建模:豆瓣电影字段清洗与特征工程
2.1 先看清字段结构再决定删什么
豆瓣电影数据常见的字段包括电影名、评分、评价人数、类型、制片国家、上映年份、导演、主演、时长、语言等。原始数据最大的问题是「一列塞多个值」,比如类型字段写成「剧情/爱情/科幻」,主演字段写成「张国荣/张丰毅/巩俐」,年份字段可能混着「1993(中国大陆)」这种带后缀的写法。直接拿这种列去做模型,等于把噪声喂给算法。
我一般先用 pandas 把结构摸清楚,看每列的非空数量、唯一值数量和几个样本值,再决定哪些列要拆、哪些列要转类型。
import pandas as pd import numpy as np # 读入时先不指定 dtype,让 pandas 自己推断,方便观察原始类型 df = pd.read_csv("douban_movies.csv") # 基础体检:看形状、缺失、类型 print(df.shape) print(df.dtypes) print(df.isnull().sum().sort_values(ascending=False).head(10)) # 抽样看几个「一列多值」的字段长什么样 for col in ["types", "countries", "actors", "year"]: if col in df.columns: print(col, df[col].dropna().sample(3, random_state=42).tolist())这段代码的作用是先做数据体检,不是急着清洗。df.isnull().sum()按缺失数量排序,能一眼看出哪些列缺得离谱,缺超过一半的列基本可以直接放弃。抽样那几行是为了确认「一列多值」的分隔符到底是/还是,,不同来源的数据分隔符不一样,写死一个分隔符是后面报错的常见原因。
参数上要注意sample的random_state,固定它才能保证每次抽样结果一致,方便复现问题。如果数据量超过内存,read_csv要加chunksize分块读,这个后面排查章节会讲。
2.2 把多值字段拆成模型能吃的特征
类型、国家、演员这些字段是典型的类别型多值特征。处理思路是先按分隔符炸开,再做 one-hot 或者计数编码。评分预测这种回归任务里,类型数量、是否合拍、主演知名度都可以变成数值特征。
# 1. 年份清洗:从 "1993(中国大陆)" 里抠出纯数字 df["year_clean"] = df["year"].astype(str).str.extract(r"(\d{4})").astype(float) # 2. 类型拆分:统计每部电影的类型数量,并给高频类型打标记 df["types"] = df["types"].fillna("未知") df["type_count"] = df["types"].apply(lambda x: len(x.split("/"))) # 用 explode 把多值行展开,再透视回宽表做 one-hot type_dummies = ( df["types"].str.split("/", expand=True) .stack() .str.strip() .to_frame("type") ) type_dummies["movie_id"] = type_dummies.index.get_level_values(0) type_wide = pd.crosstab(type_dummies["movie_id"], type_dummies["type"]) type_wide = (type_wide > 0).astype(int) # 只要 0/1,不要计数 df = df.join(type_wide, how="left")这里有几个关键点。str.extract(r"(\d{4})")用正则只抓四位数字,能同时处理「1993」「1993(中国大陆)」「1993-01-01」这几种写法,比str[:4]稳。explode加crosstab是处理多值类别最不容易出错的方式,比手写循环快很多。最后(type_wide > 0).astype(int)把计数压成 0/1,因为一部电影同一类型不会重复出现,计数没有额外信息,反而会让某些类型的权重虚高。
类型字段拆完后,国家、语言同理。演员字段如果要做「主演热度」特征,需要额外一张演员作品数表,这个在数据量小的时候收益不明显,可以先跳过,避免特征维度爆炸。
2.3 评分人数和评分的关系不是线性的
很多人直接把「评价人数」当特征丢进线性回归,结果系数很小甚至为负,就以为评价人数没用。实际上评价人数和评分是典型的非线性关系:评价人数极少的电影评分波动极大,评价人数到一定量级后评分趋于稳定。直接线性建模会把这个关系压平。
常见做法是对评价人数做对数变换,再构造一个「评价人数分桶」特征。
# 对数变换,缓解长尾分布 df["votes_log"] = np.log1p(df["votes"]) # 分桶:把评价人数切成 5 档,作为类别特征 df["votes_bucket"] = pd.qcut(df["votes"], q=5, labels=False, duplicates="drop") # 检查变换前后的分布 print(df[["votes", "votes_log"]].describe()) print(df["votes_bucket"].value_counts().sort_index())np.log1p是log(1+x),比log(x)安全,因为评价人数可能为 0。pd.qcut按分位数切桶,保证每桶样本量接近,比等宽分桶合理。duplicates="drop"是防止分位点重复导致报错,数据量小的时候经常遇到。
到这一步,特征工程的主体就完成了。剩下的标准化、缺失值填充放到建模流程里做,避免数据泄漏。
3. 用 scikit-learn 搭一条能复现的评分预测流水线
3.1 为什么选 Pipeline 而不是手写训练循环
手写train_test_split加fit加predict看起来简单,但标准化、缺失值填充这些步骤如果提前在全量数据上做,测试集的信息就泄漏进训练过程了,模型指标会虚高。Pipeline把预处理和模型绑成一个整体,fit只在训练集上做,predict时自动套用训练集的统计量,这是保证结果可信的基本操作。
from sklearn.model_selection import train_test_split from sklearn.pipeline import Pipeline from sklearn.impute import SimpleImputer from sklearn.preprocessing import StandardScaler from sklearn.ensemble import RandomForestRegressor from sklearn.metrics import mean_absolute_error, r2_score # 选定特征列,排除标签和原始文本列 feature_cols = ["year_clean", "type_count", "votes_log", "votes_bucket"] + list(type_wide.columns) X = df[feature_cols] y = df["rating"] X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42 ) pipe = Pipeline([ ("imputer", SimpleImputer(strategy="median")), ("scaler", StandardScaler()), ("model", RandomForestRegressor( n_estimators=300, max_depth=12, min_samples_leaf=3, random_state=42, n_jobs=-1 )) ]) pipe.fit(X_train, y_train) pred = pipe.predict(X_test) print("MAE:", mean_absolute_error(y_test, pred)) print("R2:", r2_score(y_test, pred))SimpleImputer(strategy="median")用中位数填充,比均值抗异常值。StandardScaler对树模型其实不是必须的,但保留它能让同一套 Pipeline 换线性模型时不用改结构。RandomForestRegressor的参数里,n_estimators=300是精度和耗时的平衡点,max_depth=12防止过拟合,min_samples_leaf=3让叶子节点至少有几个样本,避免模型记住噪声。n_jobs=-1用满所有 CPU 核心。
3.2 参数怎么调:先看学习曲线再动网格
上来就GridSearchCV跑几百个组合,在小数据集上纯属浪费时间。我一般先画学习曲线,看模型是欠拟合还是过拟合,再决定调哪个参数。
from sklearn.model_selection import learning_curve import matplotlib.pyplot as plt train_sizes, train_scores, val_scores = learning_curve( pipe, X_train, y_train, train_sizes=np.linspace(0.1, 1.0, 8), cv=5, scoring="neg_mean_absolute_error", n_jobs=-1 ) train_mae = -train_scores.mean(axis=1) val_mae = -val_scores.mean(axis=1) plt.plot(train_sizes, train_mae, label="train") plt.plot(train_sizes, val_mae, label="validation") plt.xlabel("train size") plt.ylabel("MAE") plt.legend() plt.show()如果训练 MAE 远低于验证 MAE,说明过拟合,优先降max_depth或加min_samples_leaf。如果两条线都很高且接近,说明欠拟合,优先加n_estimators或放宽max_depth。这个判断比盲目网格搜索快得多。
确认方向后再做小范围网格:
from sklearn.model_selection import GridSearchCV param_grid = { "model__max_depth": [8, 12, 16], "model__min_samples_leaf": [1, 3, 5], "model__n_estimators": [200, 300] } grid = GridSearchCV(pipe, param_grid, cv=5, scoring="neg_mean_absolute_error", n_jobs=-1) grid.fit(X_train, y_train) print(grid.best_params_) print(-grid.best_score_)注意参数名要带model__前缀,因为它们在 Pipeline 里属于model这一步。这是新手最常写错的地方,报错信息一般是「Invalid parameter」。
3.3 特征重要性怎么读才不误导
树模型自带feature_importances_,但这个值对高基数特征有偏好,类型 one-hot 出来的列多了,重要性会被稀释。更稳的做法是用 permutation importance,在验证集上打乱某一列看指标掉多少。
from sklearn.inspection import permutation_importance result = permutation_importance( pipe, X_test, y_test, n_repeats=10, random_state=42, scoring="neg_mean_absolute_error", n_jobs=-1 ) imp = pd.Series(result.importances_mean, index=feature_cols) print(imp.sort_values(ascending=False).head(15))n_repeats=10表示每列重复打乱 10 次取平均,次数太少结果不稳定。读结果时要注意,如果votes_log排第一,说明评价人数确实是强信号,这和业务直觉一致;如果某个类型排得很高,要回去看是不是样本量太小导致的偶然。
4. 报告里的结论怎么验证:从指标到业务解释的排查清单
4.1 指标好看不等于结论成立
MAE 到 0.5 左右在评分预测里算不错,但报告如果写「模型能准确预测电影评分」,这句话是站不住的。评分预测的 MAE 0.5 意味着平均差半颗星,而豆瓣评分本身集中在 6 到 9 之间,半颗星的误差已经能改变「推荐/不推荐」的判断。验证结论时要看误差分布,不只看均值。
residual = y_test - pred print(residual.describe()) print((residual.abs() > 1).mean()) # 误差超过 1 分的比例如果误差超过 1 分的比例超过 20%,报告里就不该用「准确预测」这种表述,改成「能捕捉评分趋势」更诚实。
4.2 数据泄漏的三个高发位置
第一个位置是特征工程在划分数据集之前做。比如先对全量数据算votes_log的均值再填充缺失,测试集信息就漏进去了。正确做法是把所有依赖统计量的操作放进 Pipeline。
第二个位置是时间字段。如果用上映年份做特征预测评分,而训练集和测试集的时间分布差异很大,模型学到的可能是年代偏好而不是电影质量。稳妥做法是按年份切分训练测试集,模拟「用老电影预测新电影」。
第三个位置是目标编码。如果对导演、演员做目标编码(用该导演作品的平均分作为特征),必须用交叉验证的方式计算,否则每部电影的特征里都包含了自己的评分。
4.3 类型特征的共线性怎么处理
类型 one-hot 之后,「剧情」和「爱情」经常同时出现,相关系数很高。树模型对共线性不敏感,但如果你在报告里做线性回归或者解释系数,共线性会让系数符号变得不可解释。检查方法很简单:
corr = df[type_wide.columns].corr().abs() upper = corr.where(np.triu(np.ones(corr.shape), k=1).astype(bool)) high_corr = [(c, r, upper.loc[r, c]) for c in upper.columns for r in upper.index if upper.loc[r, c] > 0.6] print(high_corr[:10])相关系数超过 0.6 的类型对,在解释时要么合并,要么只保留一个。这一步不做,报告里的「爱情片评分更高」可能是「剧情片评分更高」的伪装。
5. 避坑与排查:豆瓣电影数据分析里最容易翻车的五件事
5.1 中文列名和编码问题导致读入就报错
现象:pd.read_csv报UnicodeDecodeError,或者读进来列名是乱码。
原因:豆瓣数据导出时编码可能是 GBK 或 GB18030,而 pandas 默认按 UTF-8 读。
解决:先试encoding="gbk",不行再试encoding="gb18030"。如果还是乱码,用chardet检测编码。读入后统一把列名重命名为英文,避免后续代码里混用中英文列名。
import chardet with open("douban_movies.csv", "rb") as f: print(chardet.detect(f.read(100000)))5.2 评分字段混入非数字字符
现象:df["rating"].astype(float)报ValueError。
原因:评分列里可能有「暂无评分」「8.5分」这类值。
解决:用pd.to_numeric(df["rating"], errors="coerce"),把无法转换的变成 NaN,再决定是删还是填。不要用astype硬转,报错信息不会告诉你哪一行有问题。
5.3 评价人数为 0 导致对数变换报错
现象:np.log(df["votes"])出现-inf。
原因:评价人数为 0 时log(0)是负无穷。
解决:用np.log1p,或者先df["votes"] = df["votes"].clip(lower=1)。我一般两个都做,双保险。
5.4 训练集测试集划分后类别特征对不齐
现象:predict时报特征数量不匹配,或者 one-hot 后测试集少了几列。
原因:类型 one-hot 是在全量数据上做的,如果先划分再分别 one-hot,测试集可能没有训练集里的某个类型。
解决:one-hot 必须在划分之前做,或者用sklearn的OneHotEncoder(handle_unknown="ignore")放进 Pipeline。前者简单,后者更规范。
5.5 报告结论和模型指标对不上
现象:报告写「类型是影响评分的最重要因素」,但特征重要性里类型排得很后。
原因:可能是类型特征被拆成了几十列,单列重要性都不高,但合起来贡献大。也可能是报告作者凭直觉写的,没看实际结果。
解决:把类型特征合并成一个「类型组合」类别再做一次重要性分析,或者用分组 permutation importance。报告里的每一条结论都要能对应到一个具体的数字,对不上就删掉。
6. 把这份分析做成能复用的模板:三个进阶技巧
第一个技巧是把整条流程封装成函数,换数据集时只改读入和列名映射。我一般会写一个build_features(df)和一个train_model(X, y),中间用配置文件控制哪些列参与训练。这样下次拿到猫眼或者 IMDB 的数据,改几行列名就能跑,不用重写。
def build_features(df, type_col="types", votes_col="votes"): df = df.copy() df["votes_log"] = np.log1p(df[votes_col].clip(lower=1)) df["votes_bucket"] = pd.qcut(df[votes_col], q=5, labels=False, duplicates="drop") type_wide = pd.crosstab( df.index, df[type_col].fillna("未知").str.split("/").explode().str.strip() ) return df.join((type_wide > 0).astype(int))第二个技巧是给模型加一个「基线对比」。用所有电影的评分均值作为预测,算一个 MAE,如果模型 MAE 没比基线低多少,说明特征没提供有效信息。这个基线经常被忽略,但它是判断模型有没有用的最低标准。
baseline_pred = np.full_like(y_test, y_train.mean(), dtype=float) print("baseline MAE:", mean_absolute_error(y_test, baseline_pred))第三个技巧是把报告里的每个结论都写成一个可执行的验证单元。比如结论是「2010 年后电影评分更高」,就写一段代码按年份分组算均值并做 t 检验,而不是只放一张图。图能看趋势,但趋势是否显著要靠统计量说话。
from scipy import stats early = df[df["year_clean"] < 2010]["rating"].dropna() late = df[df["year_clean"] >= 2010]["rating"].dropna() t, p = stats.ttest_ind(early, late, equal_var=False) print(f"t={t:.3f}, p={p:.4f}")这三个技巧里,我最常用的是第一个。早期我每换一个数据集就重写一遍清洗代码,后来发现 80% 的逻辑是一样的,只有列名和分隔符不同。封装之后,新数据集从拿到到出第一版指标基本能控制在一小时内。特征工程这块没有银弹,但把重复劳动压下去,才有时间去看那些真正影响结论的细节。希望帮到你。
本文还有配套的精品资源,点击获取