基于TF-IDF、LDA与DBSCAN的观影用户聚类与电影推荐分析

基于TF-IDF、LDA与DBSCAN的观影用户聚类与电影推荐分析 简介资源面向电影推荐与文本挖掘方向的毕业设计、课程设计开发者围绕TF-IDF特征加权、LDA主题建模与DBSCAN密度聚类形成一套从评论语料处理到用户聚类、电影推荐的可运行方案。压缩包共20个文件主体为12个Python脚本完整覆盖GUI界面、主流程、聚类算法、图形绘制等核心模块另含2个TXT数据文件、1个PDF报告、1个MD说明文档及若干配置与授权文件整包约7.21MB。已有517人学习下载。项目结构清晰MD文档详细解释了TF-IDF与LDA的原理及实验设计思路Python代码分段注释便于二次开发数据文件与RAR压缩包内置了原始数据集可直接运行主程序复现分析流程也可基于GUI组件调整模型参数观察不同聚类效果。整体适用于推荐系统、文本聚类相关的毕设选题也可作为学习三种算法工程结合的实践范例。1. 观影用户的电影推荐聚类分析为什么需要文本特征与密度聚类结合这套基于 TF-IDF、LDA 与 DBSCAN 的观影用户聚类项目解决的是一个很实际的问题用户没有显式评分只有零散的影评文本和观影记录如何把“喜欢什么类型的电影”这件事变成可计算的聚类结果。很多推荐系统只盯着评分矩阵但冷启动场景下大量用户没有评分文本评论反而是最丰富的信号。TF-IDF 负责把评论变成有区分度的词向量LDA 再把高维稀疏向量压缩成几个稳定的主题分布DBSCAN 则在不预设簇数量的前提下把相似用户聚成一堆。三步串联后每个用户获得一个低维语义向量和一个簇标签后续既可以做协同过滤也可以按簇分析用户偏好。这套代码适合正在做推荐系统毕业设计的人也适合想系统走一遍 NLP 特征工程 密度聚类全流程的工程师。项目压缩包里的模块划分很清晰从文本预处理到可视化界面都有对应脚本下文按链路拆开讲。2. TF-IDF与LDA从词频统计到主题分布的特征工程原理2.1 TF-IDF词项区分度的经典度量TF-IDF 的核心思想正如摘要里所说一个词在当前文本里出现频率高但在其他文本里很少出现那么这个词对当前文本的区分度就高。公式上就是词频TF乘以逆文档频率IDFIDF 一般取log(N / (df 1))其中 N 是文档总数df是包含该词的文档数。实际使用中我们不会从零去写用 scikit-learn 的TfidfVectorizer就能完成分词、停用词过滤、向量化。from sklearn.feature_extraction.text import TfidfVectorizer comments [ 这部电影的剧情很拖沓但是配乐非常震撼, 特效一流剧情一般适合周末放松, 情节紧凑反转多导演功力深厚, ] # 实际数据来自 data.rar 内的评论文件 vectorizer TfidfVectorizer( token_patternr\b\w\b, # 按词切分中文场景需要先分词 min_df2, # 至少在2篇文档中出现 max_df0.9, # 忽略在90%以上文档中出现的词 stop_wordsenglish # 英文停用词中文需自定义 ) tfidf_matrix vectorizer.fit_transform(comments) print(tfidf_matrix.shape) # (3, 词表大小)参数里最关键的是min_df和max_df。min_df太小会保留大量只在一条评论里出现的噪声词太大又会丢掉长尾信息max_df用来剔除“的、了、电影”这类几乎每篇都有的词这些词对用户区分没有帮助。中文场景下要注意先分词常见做法是用 jieba 把评论切成带空格的词序列再传入否则token_pattern会把整句话当成一个 token。2.2 LDA主题模型把高维稀疏向量压缩成语义主题TF-IDF 矩阵的维度等于词表大小动辄上万维且极度稀疏。直接对这个矩阵做聚类距离计算会很不可靠。LDA隐含狄利克雷分配是一种生成式概率模型它假设每篇文档由若干主题混合而成每个主题又由一组词的概率分布构成。训练完后每篇文档可以表示成一个K维主题分布向量K 远小于词表大小。这里把 TF-IDF 矩阵作为 LDA 的输入而不是原始词频是因为 TF-IDF 已经去掉了停用词和常见词的干扰能让主题更聚焦于有区分度的内容。from sklearn.decomposition import LatentDirichletAllocation lda LatentDirichletAllocation( n_components8, # 主题数量需要根据困惑度或人工观察调 max_iter20, # EM 迭代次数 learning_methodonline, # 在线学习适合数据量大时 random_state42 ) # tfidf_matrix 来自上一个代码块直接喂给 LDA doc_topic_dist lda.fit_transform(tfidf_matrix) print(doc_topic_dist.shape) # (3, 8)每一行是文档的主题比例 print(lda.components_.shape) # (8, 词表大小)参数里n_components是主题数一般取 530 之间。主题数太少会把“科幻”和“悬疑”混在一起太多又会产生大量重叠主题。learning_method选online能在大语料上更快收敛batch适合小数据集但内存要求高。训练后可以通过lda.components_查看每个主题下权重最高的词人工核对主题语义是否合理。2.3 为什么聚类环节选DBSCAN而不选K-Means拿到每篇文档的主题分布向量后就要对用户聚类。项目里同时提供了DBSCAN.py和kMean.py显然不是为了做对比实验而是作为两种可替换方案。K-Means 需要预先指定簇数 K而且对噪声敏感一个偏离很远的用户会被强行拉进某个簇。DBSCAN 基于密度连通性自动发现任意形状的簇并把稀疏区域的点标记为噪声。对用户聚类来说很多用户可能没有明显的归属群体DBSCAN 能保持这些异常点独立而不是硬分配。对比项K-MeansDBSCAN簇数量需指定 K自动确定形状偏向凸形任意形状噪声处理无特殊机制噪声影响质心显式标记为 -1 簇参数Keps, min_samples适用场景已知类别数、数据较均匀密度不均、含异常点主题分布向量通常是稠密的低维浮点向量密度分布往往不是球形DBSCAN 的密度连通概念比 K-Means 的欧氏距离更贴合这种数据结构。但如果主题向量分布很均匀、密度差异小DBSCAN 可能反而把大部分点都归为一个簇这时候用 K-Means 配合轮廓系数选 K 更稳定。项目在DBSCAN.py里保留了完整调参入口下面章节会展开讲如何标定 eps。3. 项目完整代码结构从数据处理到聚类输出3.1 代码文件结构与职责这个压缩包里的文件不是一堆散乱的脚本每个文件承担一个明确阶段。拿到压缩包先解压先看readme.txt和README.md里面写了运行顺序。Main.py是主入口负责组装整个流程Graphic1.py、Graphic2.py、Graphic3.py、Graphic4.py对应不同维度的可视化结果Fourier Transform.py与主流程关系不大更像是附带的信号处理脚本建议忽略data.rar存放原始数据dbscanData.txt是 DBSCAN 的输入样本。文件职责Main.py编排流程读数据 - TF-IDF - LDA - DBSCAN - 输出结果Graphic1.py绘制 TF-IDF 词频分布或评论长度分布Graphic2.py绘制 LDA 主题内词权重的条形图Graphic3.py绘制 K-Means 聚类散点图Graphic4.py绘制 DBSCAN 聚类散点图并高亮噪声点MyBrowser.py / MyResultListWidget.py基于 PyQt 的推荐结果列表组件DBSCAN.pyDBSCAN 算法实现与封装kMean.pyK-Means 聚类脚本Huang.py可能是数据清洗或额外的用户画像脚本3.2 特征提取核心实现TF-IDF LDA从data.rar解压后典型的影评数据是 CSV 或 JSON每行有用户 ID、电影 ID、评论文本。先用 jieba 分词和去停用词再做 TF-IDF然后 LDA。注意 LDA 的输入一般建议用原始词频矩阵但这里用 TF-IDF 矩阵是可行的只要先对矩阵做非负化处理因为 TF-IDF 值本身是非负的。import pandas as pd import jieba from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.decomposition import LatentDirichletAllocation # 1. 加载数据假设 data/comment.csv 有 user_id, movie_name, content 列 df pd.read_csv(data/comment.csv, encodingutf-8) df[content] df[content].fillna() # 2. 分词与停用词过滤 def tokenize(text): words jieba.lcut(text) return .join(w for w in words if w.strip() and len(w) 1) df[tokens] df[content].apply(tokenize) # 3. TF-IDF 向量化 tfidf TfidfVectorizer(min_df2, max_df0.9) tfidf_matrix tfidf.fit_transform(df[tokens]) print(TF-IDF 矩阵:, tfidf_matrix.shape) # 4. LDA 主题降维 lda LatentDirichletAllocation( n_components10, max_iter30, learning_methodonline, random_state7 ) doc_topic lda.fit_transform(tfidf_matrix) print(LDA 主题分布:, doc_topic.shape) # 5. 按用户聚合一个用户可能有多个评论取主题分布均值作为用户向量 user_vec df.groupby(user_id)[list(range(10))]这里有个容易漏掉的坑df.groupby不能直接对二维数组分组正确做法是把doc_topic拼到df的每一行再按user_id做均值。聚合后的user_vec才是最终用于 DBSCAN 的用户向量。LDA 的n_components对应 10 维主题比例每一维之和为 1但聚类算法不要求这种约束直接使用即可。3.3 DBSCAN聚类与结果保存拿到用户主题向量后DBSCAN 只需要两个参数eps和min_samples。eps是邻域半径min_samples是成为核心点所需的最少样本数。项目里的DBSCAN.py对 sklearn 的DBSCAN做了一层封装方便传入向量文件或数组。from sklearn.cluster import DBSCAN from sklearn.preprocessing import StandardScaler import numpy as np # user_vec: ndarray, shape (n_users, n_topics) # 建议先标准化否则主题分布方差会被个别维度放大 scaler StandardScaler() user_vec_scaled scaler.fit_transform(user_vec) # 用 KNN 距离图辅助选 eps见第 4 节 model DBSCAN(eps0.85, min_samples5, metriceuclidean) labels model.fit_predict(user_vec_scaled) # 保存结果 result pd.DataFrame({ user_id: user_ids, cluster_label: labels }) result.to_csv(cluster_result.csv, indexFalse, encodingutf-8-sig) # 统计每个簇的用户数 print(result[cluster_label].value_counts()) # -1 表示噪声点StandardScaler在这里不是可选项而是必选项。LDA 的主题分布虽然是概率但不同主题的方差可能差一个数量级比如第三个主题在大多数用户上都是 0.1 左右第五个主题则在 0.4 附近波动这样计算欧氏距离时低方差主题的贡献会被高方差主题淹没聚类结果基本只由方差最大的那一维决定。metriceuclidean是常用选择如果用户向量的分布是超球面也可以换成cosine但 DBSCAN 的eps对距离度量很敏感换度量必须重新调参。3.4 可视化与交互界面Graphic1.py到Graphic4.py分别从不同角度展示中间结果TF-IDF 词频分布图、LDA 主题词条形图、K-Means 的肘部散点、DBSCAN 的簇分布。这些脚本直接运行会弹出 matplotlib 窗口如果需要批量生成图片可以在脚本底部把plt.show()换成plt.savefig()。MyBrowser.py基于 PyQt 的 QWebEngineView 实现了一个内嵌浏览器用来展示推荐电影详情页MyResultListWidget.py自定义了一个带封面和评分的列表组件这两个文件可以独立运行不依赖聚类流程方便在 UI 上做演示。4. DBSCAN调参实战eps、min_samples与聚类效果评估4.1 参数敏感性分析DBSCAN 的eps是最难调的参数它对结果的影响远超min_samples。eps太小大量用户被标记为噪声簇碎片化eps太大几乎所有用户被划进一个大簇。常用辅助方法是画 KNN 距离曲线对每个用户计算到第k近邻的距离排序后绘制折线图拐点对应的距离就是参考eps。这里k取min_samples - 1。from sklearn.neighbors import NearestNeighbors neigh NearestNeighbors(n_neighbors5) # 取 min_samples5 时k4 neigh.fit(user_vec_scaled) distances, indices neigh.kneighbors(user_vec_scaled) # 取每个点到第 4 近邻的距离排序后绘图 k_dist np.sort(distances[:, -1]) # 用 matplotlib 画折线拐点即 eps 参考值 import matplotlib.pyplot as plt plt.plot(k_dist) plt.xlabel(Points sorted by distance) plt.ylabel(4th neighbor distance) plt.show()实际操作中拐点并不一定清晰。一种更工程化的做法以eps从 0.3 到 1.5 步长 0.05 扫描记录每种参数下的簇数量、噪声占比、轮廓系数挑一个噪声占比在 10%30% 之间且簇数量稳定的区间。min_samples越大对噪声的抗干扰越强但也会把边缘的小簇磨平。对于几百到几千用户的场景min_samples取 5 或 10 是起步值如果用户量有几万可以升到 20让核心点更鲁棒。4.2 聚类效果度量轮廓系数与噪声比例调参不能只靠肉眼。DBSCAN 没有 score 函数需要用轮廓系数评估簇内外距离的合理程度。轮廓系数取值为 [-1, 1]越高表示样本离自己簇越近、离相邻簇越远。注意轮廓系数要排除标签为 -1 的噪声点因为噪声点本身不属于任何簇。from sklearn.metrics import silhouette_score # labels 是 DBSCAN 的聚类结果 mask labels ! -1 if np.sum(mask) 1 and len(set(labels[mask])) 1: sil silhouette_score(user_vec_scaled[mask], labels[mask]) noise_ratio (labels -1).mean() print(f轮廓系数: {sil:.3f}噪声占比: {noise_ratio:.1%}) else: print(有效簇太少无法计算轮廓系数)扫描不同参数组合可以输出一张对照表eps为 0.65 时簇数 9噪声 24%轮廓系数 0.41eps为 0.75 时簇数 6噪声 15%轮廓系数 0.38。这种情况下我更愿意选簇数略少但噪声占比更低的组合因为簇数太多会导致后续推荐逻辑难以维护每个簇的用户基数太小推荐列表空洞。还有一种情况轮廓系数很高但噪声占比吓人说明多数点被孤立了只剩下几小撮紧密的点这也不是好结果要结合两个指标判断。4.3 常见踩坑稀疏矩阵、维度诅咒、随机种子第一个坑是直接对 TF-IDF 稀疏矩阵做 DBSCAN。sklearn 的 DBSCAN 支持稀疏输入但距离计算在高维稀疏空间里非常慢而且绝大多数距离都是 0密度意义失真。正确的顺序是先 LDA 降维到 1020 维稠密向量再做 DBSCAN。如果 LDA 效果不好可以用 TruncatedSVD 降维效果相似。第二个坑是维度诅咒。LDA 主题数设得太大比如 50 维所有用户彼此之间都像在“高维空间的边缘”距离分布趋向均匀eps几乎没法选。我一般限制主题数在 520 之间并用困惑度或主题词可解释性来选。第三个坑是随机种子没有固定。LDA 的 EM 迭代有随机初始化DBSCAN 虽然没有随机性但 LDA 每次跑出来的主题分布都不一样会导致聚类结果飘。代码里必须设置random_state否则报告里的结果无法复现。上面代码里 LDA 固定了random_state7StandardScaler和 DBSCAN 不依赖随机种子所以整个流程可复现。5. 从聚类到电影推荐用用户分群提升推荐精度5.1 聚类结果的业务映射聚类完成后每个用户都有了一个簇标签下一步不是急着把代码跑完就交差而是先分析每个簇的语义。以 6 个簇为例打印每个簇内用户评论中出现最多的 LDA 主题关键词手动给每个簇定名簇 0 偏重“悬疑烧脑”簇 1 偏重“特效大片”簇 2 偏重“文艺爱情”簇 3 是“动画家庭片”。这一步看起来简单却决定了推荐策略是否可信。定名之后每个簇变成一个可解释的用户群体后续可以做差异化运营比如给“悬疑烧脑”簇的用户推小众悬疑片而不是热门爆米花片。另外核心要素是保留噪声用户标签 -1他们占 10%30%推荐时可以退回到大众热门榜兜底。5.2 一个轻量的推荐策略簇内评分加权既然没有显式评分可以用“用户是否评论过这部电影”作为行为信号评论本身就是一种正反馈。对于目标用户先找到他所在簇的其他用户统计这些用户评论的电影按评论用户数加权再减去该电影在全体用户中的热度得到偏好增强得分最后推荐 top-N。# cluster_label: 用户所属簇df_comment: 用户-电影评论文本表 import pandas as pd import numpy as np def recommend_for_user(target_user_id, user_cluster, df_comment, top_n10): target_cluster user_cluster.loc[target_user_id] # 簇内用户评论的电影计数 cluster_users user_cluster[user_cluster[cluster_label] target_cluster].index cluster_stats df_comment[df_comment[user_id].isin(cluster_users)] \ .groupby(movie_id)[user_id].count().rename(cluster_cnt) # 全体用户计数用于降低热门对公共偏好的偏移 global_stats df_comment.groupby(movie_id)[user_id].count().rename(global_cnt) score pd.concat([cluster_stats, global_stats], axis1).dropna() # 簇内权重 0.7全局热度惩罚 0.3防止推到过于大众的片 score[enhanced_score] 0.7 * score[cluster_cnt] - 0.3 * score[global_cnt] # 排除已评论过的电影 watched set(df_comment[df_comment[user_id] target_user_id][movie_id]) candidates score[~score.index.isin(watched)] \ .sort_values(enhanced_score, ascendingFalse).head(top_n) return candidates.index.tolist()这个策略的权重分配是经验值。0.7 和 0.3 的意思是当一个电影在簇内被 10 个人评论过但在全站被 100 个人评论时得分是 7 - 30 -23会被排除而被簇内 20 人、全站 30 人评论时得分是 14 - 9 5排前面。这样既避免推荐过于冷门又避免推荐给谁都一样的头部热门。实际项目中可以把这个权重作为超参数用离线命中率来调。user_cluster可以是第 3 节生成的cluster_result.csv如果 DataFrame 的索引是数字用户 IDloc[target_user_id]就能取到标签。5.3 验证推荐效果与迭代思路推荐效果不能只靠视觉判断。一个离线验证方法是把评论数据按时间切分前 80% 用来聚类和计算推荐后 20% 作为测试集检查推荐的电影是否真的被用户看了。常用指标是 precisionK每个用户测试集里前 K 条电影被推荐命中的比例然后求全体均值。如果连这个基础指标都低于随机推荐按全站热度推荐就说明聚类没有带来有效分群需要回到 LDA 主题数或 DBSCAN 参数重新调。还有一种代价更低的验证人工检查每个簇内用户的“最近观看六部电影”重叠度如果同一个簇的人看的片单高度雷同说明聚类捕捉到了真实偏好推荐才有依据。迭代时优先调整 LDA 的主题词可解释性而不是死磕 DBSCAN 的eps因为主题分布失真时聚类再精细也是建立在错误语义之上的。本文还有配套的精品资源点击获取