ARTICLE DETAIL

资讯详情

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

TF-IDF+LDA+DBSCAN:基于用户观影文本聚类的电影推荐方案

TF-IDF+LDA+DBSCAN:基于用户观影文本聚类的电影推荐方案 简介基于TF-IDF、LDA与DBSCAN三种算法的观影用户电影推荐聚类分析毕业设计项目面向推荐系统、文本挖掘方向的学习者及毕业设计开发者。资源将TF-IDF特征提取、LDA主题建模和DBSCAN密度聚类相结合形成从用户评论/标签文本到电影聚类推荐的完整流程适合用于理解无监督学习在推荐场景中的应用。压缩包共20个文件以12个Python脚本为核心涵盖数据预处理、聚类算法实现、图表绘制与GUI交互界面同时包含2个文本数据/说明文件、1个PDF报告、1个RAR数据包以及README.md等辅助资料包体仅7.21MB结构紧凑、易于上手。资源已有517人学习下载。除了完整可运行的代码还附带报告和数据文件便于读者对照论文梳理设计思路可直接调试或二次扩展是完成课程设计、毕业设计或算法对比实验的高性价比参考。1. 把观影行为变成文本特征再做密度聚类这条链路为什么适合电影推荐在真实的评分数据集里用户与电影的交互往往稀疏到 5% 都不到这种矩阵直接喂给协同过滤冷启动和热门垄断几乎是必然结果。一个更稳定的处理思路是把每个用户看过的电影简介、类型标签、剧情关键词拼成一段文本先由 TF-IDF 提取关键词权重再用 LDA 压缩成少数几个主题分布最后用 DBSCAN 对这些低维向量做密度聚类。每一步都有可解释的中间产物TF-IDF 告诉你用户在意哪些词LDA 告诉你用户口味落在哪几个主题DBSCAN 把密度相近的用户划进同一个簇。推荐时直接用簇内群体的联合偏好做召回比单纯用评分数值抗稀疏得多也正好构成毕业设计里“原理、参数、评估”三段完整的算法叙事。2. TF-IDF 与 LDA 构建用户偏好特征从交互表到主题向量的完整流程2.1 为什么用 TF-IDFLDA而不是直接拿评分矩阵或词向量评分矩阵的问题不只是稀疏评分口径也不统一。有人习惯打 4 分以上有人把 3 分当作最高评价原始数字直接作为特征会给聚类引入系统性偏差。把观影行为转成文本后建模对象从“缺了大量值的矩阵”变成了一段有语义的词序列用户偏好被重新表达为“他反复看过哪些类型、哪些关键词”。TF-IDF 对词的加权逻辑很适合电影场景一个词在某用户文本中出现得多却在全量用户中出现得少说明这个词对该用户有区分度。像“诺兰”“漫威”“治愈系”“武侠”这类高频主题词都能被单独拎出来。相比 Word2Vec 或 BERTTF-IDF 不需要训练维度可控词表可以打印出来人工检查这对毕业设计答辩是巨大优势。LDA 接在 TF-IDF 之后把八千年维度的稀疏向量压缩到十几个概率分量既完成降维也带出“主题”这个可解释的中间层。2.2 用 jieba 构建用户级语料保留原始交互信息假设输入有两张表movies 表包含 movie_id、title、genres、overviewratings 表包含 user_id、movie_id、rating、timestamp。第一步按 user_id 分组把每个用户看过的电影文本拼接成一条用户文档import jieba import pandas as pd def build_user_corpus(df_ratings, df_movies): df df_ratings.merge( df_movies[[movie_id, genres, overview]], onmovie_id, howleft ) df[text] df[overview].fillna() df[genres].fillna() user_docs {} for uid, grp in df.groupby(user_id): tokens [] for t in grp[text]: if t and t.strip(): tokens.extend(jieba.lcut(t)) user_docs[uid] .join(tokens) return user_docs这段代码把“一部电影的简介和类型”整体先拼接再按用户汇总后分词。注意这里没有显式做停用词过滤因为后面的 TfidfVectorizer 可以用 min_df 和 max_df 一次性裁掉常用无意义词比手工维护停用词表省事。user_docs 的 key 是 user_idvalue 是长文本后面送入向量化器时需要把 values 转成列表同时单独保存一份 user_ids 列表保证行顺序能和聚类结果对得上。2.3 TfidfVectorizer 参数设置max_df、min_df、ngram_range 怎么定from sklearn.feature_extraction.text import TfidfVectorizer vectorizer TfidfVectorizer( max_features8000, max_df0.75, min_df5, ngram_range(1, 2), sublinear_tfTrue ) X_tfidf vectorizer.fit_transform(list(user_docs.values())) print(X_tfidf.shape) # (n_users, 8000)四个参数里最容易出错的是 max_df 和 min_df。max_df0.75 表示在 75% 以上文档里出现过的词直接丢弃这类词通常是“电影”“剧情”这种无区分度的词min_df5 表示至少在 5 篇文档里出现才保留用于过滤拼写错误和一次性冷门词。ngram_range(1,2) 是为了保留“科幻片”“动作片”这类二元词组。sublinear_tfTrue 则是把词频用 1log(tf) 平滑避免观影量大的用户在文本长度上碾压轻度用户。参数建议值作用与注意点max_features8000限制词表规模直接影响 LDA 训练速度max_df0.75去掉文档频率过高的通用词min_df5去掉只出现一两次的噪声词ngram_range(1,2)增加词组表达主题词更完整sublinear_tfTrue用对数平滑词频削弱长文本优势X_tfidf 的行索引与 list(user_docs.values()) 的顺序一致列索引对应当前词典列名可以用 vectorizer.get_feature_names_out() 随时查回来。2.4 LDA 主题数量选择从困惑度到主题词检查LDA 在这条链路里最重要的作用是把 8000 维的 TF-IDF 向量压到十几个维度让后续 DBSCAN 的距离计算稳定。维度越高欧氏距离越趋向均匀eps 会变得极难调。from sklearn.decomposition import LatentDirichletAllocation lda LatentDirichletAllocation( n_components12, max_iter50, learning_methodbatch, random_state42 ) X_topic lda.fit_transform(X_tfidf) print(X_topic.shape) # (n_users, 12)主题数 n_components 的选择我一般会在 8、10、12、15 之间各跑一次每次打印主题下的 top terms 做人工判断。如果某个主题的词表是“爱情、家庭、治愈、女性”另一个是“科幻、宇宙、特效、外星”说明切分有效如果所有主题里都反复出现同一批词说明主题数偏少或者文本本身区分度不够。learning_method 在数据量几万条以内用 batch 更稳定online 虽然快但引入的学习率参数会让复现变麻烦。2.5 把主题转成可读词表留给推荐解释阶段def print_topics(lda_model, vectorizer, top_n8): vocab vectorizer.get_feature_names_out() topic_words {} for idx, topic in enumerate(lda_model.components_): top_terms [vocab[i] for i in topic.argsort()[:-top_n - 1:-1]] topic_words[idx] top_terms print(f主题 {idx}: {, .join(top_terms)}) return topic_words topic_words print_topics(lda, vectorizer)这个 topic_words 字典后面会根据它生成推荐解释文案。需要特别注意的是LDA 输出的是概率分布直接拿原始分布去算距离时主导主题的方差会盖过其他维度的差异所以下一步聚类之前必须做标准化。提示只看了 1 部电影的用户文档过短LDA 给出的主题分布往往非常平均和真实偏好没有关联。建议在聚类前用 min_user_movies 阈值筛掉这批人后续单独走冷启动推荐分支。3. DBSCAN 聚类用户群体K 距离图、核心参数与评估回流3.1 为什么选 DBSCAN 而不随手用 KMeans用户观影偏好在特征空间里并不是均匀分布的大众片观众数量大、密度高冷门片观众零散稀疏还有大量随机行为的低频用户散布在空间中。KMeans 需要预先给定聚类数而且强制每个点都归属某个簇结果就是噪声用户被硬拉到最近的簇中心簇的纯度被拉低。DBSCAN 的机制不同一个点只有在 eps 半径内拥有至少 min_samples 个邻居才被认定为核心点核心点靠密度连通生成簇不满足条件的点直接标记为 -1。这意味着聚类数不需要预先指定也允许用户不归属于任何簇。对于用户聚类这个场景不需要被硬分的离群用户反而是更自然的处理方式。3.2 先标准化再画 K 距离图eps 的初始值从哪来LDA 输出虽然都在 [0,1] 区间但不同主题的方差差别很大。DBSCAN 用欧氏距离判断邻居关系如果某几个主题在所有用户里几乎不变它们就成了噪声维度。解决办法是先做一次标准化from sklearn.preprocessing import StandardScaler X_scaled StandardScaler().fit_transform(X_topic)标准化之后每个主题维度的方差都是 1算距离时权重才可比。接下来用 K 距离图估计 eps。做法是计算每个点到第 k 近邻居的距离把所有距离排序画成曲线from sklearn.neighbors import NearestNeighbors import numpy as np k 10 # 通常与 min_samples 保持一致 nn NearestNeighbors(n_neighborsk).fit(X_scaled) distances, _ nn.kneighbors(X_scaled) k_dist np.sort(distances[:, -1]) import matplotlib.pyplot as plt plt.plot(k_dist) plt.xlabel(points sorted by distance) plt.ylabel(k-th nearest distance) plt.title(K-distance plot for DBSCAN) plt.grid(True) plt.show()曲线拐点对应的高度就是 eps 的起点。如果曲线平滑下降没有明显拐点说明特征空间本身没有形成清晰的密度结构这时要先回头检查 LDA 主题数而不是硬调 eps。3.3 DBSCAN 聚类实例与 eps/min_samples 网格调参from sklearn.cluster import DBSCAN db DBSCAN( eps0.9, min_samples10, metriceuclidean ) labels db.fit_predict(X_scaled) n_clusters len(set(labels)) - (1 if -1 in labels else 0) n_noise int((labels -1).sum()) print(f聚类数: {n_clusters}, 噪声点: {n_noise}, 噪声占比: {n_noise / len(labels):.2%})eps 和 min_samples 的组合直接影响结果形态参数含义调大调小eps邻居半径簇更少更大噪声减少簇更碎噪声增多min_samples核心点最少邻居数噪声更多簇更少伪簇增多实际操作中我习惯用网格循环跑一组参数记录轮廓系数、簇数和噪声占比再选一个平衡点。网格范围一般是 eps 取 0.6 到 1.4min_samples 取 5 到 20。DBSCAN 最坏复杂度是 O(n²)用户量超过 10 万时 fit_predict 会明显卡顿毕业设计的数据量通常远低于这个规模不需要额外优化。3.4 用轮廓系数和噪声占比评估聚类结果DBSCAN 没有真实标签只能用内部指标评估。轮廓系数衡量簇内紧密度和簇间分离度的相对关系取值在 [-1,1]聚类结果大于 0.2 才说明特征空间里有结构。from sklearn.metrics import silhouette_score, calinski_harabasz_score valid_mask labels ! -1 if valid_mask.sum() 20 and len(set(labels[valid_mask])) 1: sil silhouette_score(X_scaled[valid_mask], labels[valid_mask]) ch calinski_harabasz_score(X_scaled[valid_mask], labels[valid_mask]) print(f轮廓系数: {sil:.3f}, CH指数: {ch:.1f}) else: print(有效簇太少需要调整 eps / min_samples)这里最容易被忽略的是计算轮廓系数前必须把噪声点剔除。如果把 -1 标签一起传进去sklearn 会把噪声当成一个巨大而分散的簇轮廓系数被严重拉低。除了轮廓系数噪声占比也要记录10% 到 25% 属于正常范围超过 30% 说明 eps 太小低于 5% 则可能把不同的用户群合并在了一起。网格调参的结果可以整理成一张表epsmin_samples簇数噪声占比轮廓系数0.652438%0.180.8101314%0.271.01098%0.16上面的例子中eps0.8、min_samples10 是平衡点簇数足够支撑细粒度推荐噪声占比和轮廓系数都可接受。4. 基于聚类结果的电影推荐候选池召回、排序与冷启动4.1 从 cluster_id 到推荐候选池召回层的处理顺序聚类完成之后每个用户有了族标签或 -1。推荐逻辑分成三步先从同簇用户的全部观影记录里收集候选池再从候选池排除当前用户已经看过的电影最后对候选池里的电影计算得分并取 top-N。这里没有引入新的协同过滤模型而是在簇层面做统计推荐每一部电影被推荐的理由都能追溯到“同簇多少用户看过、平均分多少”。实现的前提是 labels 的下标和 user_docs 列表顺序一致所以前面保存 user_ids 列表的习惯在这一步会节省大量排查时间。4.2 评分排序用贝叶斯平滑时间衰减权重可以后补直接用平均分排序会出现一个经典问题某部冷门片只有 2 个人打分且都是 5 分会碾压 5000 人打分、平均 4.5 的热门影片。贝叶斯平滑的思路是让投票数少的电影向全局均值回归投票数越多越信任原样本均值。import pandas as pd def build_candidate_pool(cluster_id, target_user, labels, ratings, top_k50, m10): members [uid for uid, lab in zip(ratings[user_id], labels) if lab cluster_id] watched set(ratings.loc[ratings[user_id] target_user, movie_id]) pool ratings[ratings[user_id].isin(members)] pool pool[~pool[movie_id].isin(watched)] item_stat pool.groupby(movie_id).agg( mean_rating(rating, mean), vote_count(rating, count) ).reset_index() global_mean pool[rating].mean() item_stat[score] (item_stat[vote_count] * item_stat[mean_rating] m * global_mean) / (item_stat[vote_count] m) return item_stat.sort_values(score, ascendingFalse).head(top_k)m 是平滑强度取值越大冷门电影越难上榜取值越小少数高分冷门片越容易冲进推荐列表。一般从 10 开始试根据推荐结果的多样性调整。如果数据集只有交互记录没有评分直接把 vote_count 当 score 用退化为簇内热榜。如果数据里有 timestamp可以再加时间衰减权重让最近 30 天的行为比半年前更有效pool[recency_days] (pool[timestamp].max() - pool[timestamp]) / 86400 pool[weight] pool[rating] * 0.8 np.exp(-pool[recency_days] / 30) * 0.24.3 噪声用户走冷启动分支全局热度与近邻补充DBSCAN 标成 -1 的用户有两个特征观影量少或者行为模式反复横跳。这类用户直接参与簇内统计会把推荐池的负面数据带进来所以我通常把簇推荐函数和冷启动函数分开写def recommend(user_id, labels, ratings, n20): cluster labels[user_id] if cluster -1: return popularity_based(ratings, nn) return build_candidate_pool(int(cluster), user_id, labels, ratings, top_kn)冷启动分支的基础版本是一个按评分人数加权的全局热度榜。如果想做得再细一点可以在聚类前的特征空间里找距离该噪声用户最近的 K 个有归属用户用这些用户所在簇的偏好做加权推荐。这种方式相当于对冷启动用户做了一次软聚类效果通常比单纯热榜好但需要额外维护一份近邻索引。4.4 用 LDA 主题生成推荐理由可解释性直接落地前面的 topic_words 字典在推荐阶段可以变成推荐理由topic_dist X_topic[user_id] leading_topic int(np.argmax(topic_dist)) reason .join(topic_words[leading_topic][:3]) print(f为你推荐以下电影因为你所在的观影群体偏好{reason})这里输出的理由直接来自 LDA 主题词属于整条链路里最直观可展示的产物。TF-IDF 提供词表LDA 提供群体主题DBSCAN 提供同好簇推荐阶段三者的成果被串成一句用户能看懂的话。5. 收尾技巧参数网格记录、可视化三件套与中间结果存档5.1 先跑一个全局热度 baseline再谈调参正式调参之前我建议先跑一个非个性化的全局热度榜作为基线比如按贝叶斯平滑后的 score 直接取 top-10。假设这个榜的命中率是 6%之后所有簇内推荐方案都要以超过这个数字为目标。这种对比比“推荐列表看起来挺准”更有说服力也是毕业设计答辩里最容易被认可的实验设计。5.2 可视化三件套主题热力图、PCA 散点图、主题雷达图第一张图是主题-聚类热力图横轴为 LDA 主题纵轴为 DBSCAN 簇颜色表示簇内用户在该主题上的平均概率。这张图能清楚展示“簇 0 密集在主题 2簇 1 密集在主题 7”这类结论import seaborn as sns cluster_topic_mean np.zeros((n_clusters, X_topic.shape[1])) for c in range(n_clusters): cluster_topic_mean[c] X_topic[labels c].mean(axis0) sns.heatmap(cluster_topic_mean, cmapYlGnBu) plt.xlabel(LDA 主题) plt.ylabel(DBSCAN 聚类) plt.show()第二张图是把标准化后的特征用 PCA 压到二维按 labels 着色。相比 t-SNEPCA 不引入随机性更容易复现噪声点展示为散布在簇外的灰色点能直观说明 DBSCAN 的密度聚类特性。第三张图是雷达图每个簇在主要主题上构成一条闭合曲线簇画像的差异一眼可见。三张图组合起来基本就是算法实验章节的全部素材。5.3 中间结果与 user_id 映射的存档调参过程中最容易出现的情况换了一组 LDA 主题数重新跑聚类标签全部变化结果前面记录的推荐结果对不上了。解决方法是每跑完一轮就保存全部中间变量np.save(X_tfidf_sparse.npy, X_tfidf.toarray()) np.save(X_topic.npy, X_topic) np.save(cluster_labels.npy, labels) np.save(user_ids.npy, np.array(list(user_docs.keys())))同时把 user_id 到 cluster 的对应关系导出成 CSV。后面换参数重跑时只需要重新执行标准化和聚类部分分词、向量化、LDA 拟合的结果直接从文件加载能省掉大部分重复计算时间。本文还有配套的精品资源点击获取
返回列表