简介:这份资源是面向推荐系统入门与进阶学习者的实战项目包,围绕豆瓣电影用户数据,实现了一套融合Canopy预处理与K-means聚类的混合推荐算法,并以协同过滤为基础生成个性化电影推荐。适合具备Python基础、希望理解聚类与推荐算法如何协同工作的开发者,可用于课程设计、毕业项目或算法练手。压缩包为rar格式,整体约88.88MB,上游未提供具体文件清单,从项目描述看应包含数据预处理、特征提取、Canopy粗聚类、K-means细化、用户相似度计算与推荐生成等模块的代码与数据。目前已有579人学习下载,说明该案例具备一定参考价值。读者可借此掌握从原始观影数据清洗到推荐列表输出的完整链路,理解Canopy如何缩小K-means计算范围、协同过滤如何基于相似用户行为补全推荐结果,并可将这套混合策略迁移到电商、音乐、新闻等场景,形成可复用的算法思路与排错经验。
1. 混合聚类推荐算法:从豆瓣数据到个性化推荐,这份源码包能跑通什么
如果你手头有一份用户-电影评分矩阵,想快速搭一个能出推荐结果的聚类方案,又不想从零推导公式,那这份「GitHub_混合聚类推荐算法.rar」值得拆开看看。它用豆瓣电影用户数据做底子,把 Canopy 预处理和 K-means 核心聚类串起来,再挂上协同过滤做推荐生成,整条链路是完整的。适合两类人:一是刚接触推荐算法的学生或转行者,想找一个能跑通、能改参数的实战项目;二是已经会调 Scikit-learn,但没试过把聚类和协同过滤拼在一起用的开发者。它解决的核心问题是:在大规模用户数据上,直接用 K-means 跑得慢、初始中心敏感,而 Canopy 先粗筛一遍,能把计算范围压下来,推荐结果也更稳。下面按「数据怎么进、聚类怎么跑、推荐怎么出、坑在哪」的顺序拆。
2. 数据预处理与特征提取:豆瓣评分矩阵怎么变成算法能吃的格式
2.1 原始数据的典型结构和清洗动作
豆瓣电影用户数据常见的形式是长表:一行一条评分记录,字段包括用户 ID、电影 ID、评分值、时间戳。直接丢给聚类算法是不行的,因为 Canopy 和 K-means 处理的是数值向量,不是原始日志。我一般会先做三件事:去重、去缺失、把评分转成用户-电影矩阵。去重是因为同一用户对同一电影可能有多条记录,保留最新一条;去缺失是剔除评分空值;转矩阵是把长表 pivot 成「行是用户、列是电影、值是评分」的宽表。这个宽表就是后续所有算法的输入。
import pandas as pd import numpy as np # 读取原始评分数据,假设字段为 user_id, movie_id, rating, timestamp raw = pd.read_csv('douban_ratings.csv') # 去重:同一用户对同一电影保留最新时间戳的记录 raw = raw.sort_values('timestamp').drop_duplicates( subset=['user_id', 'movie_id'], keep='last' ) # 去缺失:评分字段不能为空 raw = raw.dropna(subset=['rating']) # 转成用户-电影矩阵,缺失评分填 0 表示未观看 matrix = raw.pivot_table( index='user_id', columns='movie_id', values='rating' ).fillna(0) print(matrix.shape) # 例如 (5000, 3000),5000 个用户,3000 部电影这段代码的逻辑很直白:sort_values加drop_duplicates保证每个用户-电影对只有一条记录;pivot_table把长表转宽表;fillna(0)把没看过的电影标成 0,而不是缺失值。参数上要注意keep='last',因为豆瓣数据里用户可能改过评分,保留最新一次更合理。fillna(0)是个权衡:0 表示「未交互」,不是「评了 0 分」,后续算相似度时要区分开,否则会把没看过的电影当成差评。
2.2 特征提取:从评分矩阵到用户向量
宽表本身就可以当特征矩阵用,但直接拿几千维的稀疏向量去算距离,效果和速度都不好。常见做法是降维或者提取统计特征。我一般会先算每个用户的平均评分、评分标准差、观影数量,作为辅助特征;如果维度太高,再用 TruncatedSVD 把矩阵压到 50 到 100 维。这样每个用户就变成一个低维稠密向量,Canopy 和 K-means 跑起来快很多。
from sklearn.decomposition import TruncatedSVD # 先算用户统计特征 user_stats = pd.DataFrame({ 'mean_rating': matrix.replace(0, np.nan).mean(axis=1), 'std_rating': matrix.replace(0, np.nan).std(axis=1), 'watch_count': (matrix > 0).sum(axis=1) }).fillna(0) # 用 SVD 把评分矩阵压到 64 维 svd = TruncatedSVD(n_components=64, random_state=42) user_vectors = svd.fit_transform(matrix) # 把统计特征拼上去 user_features = np.hstack([user_vectors, user_stats.values]) print(user_features.shape) # (5000, 67)这里replace(0, np.nan)是为了在算均值和标准差时排除未观看的电影,否则 0 会拉低平均值。TruncatedSVD的n_components设 64 是个经验值,维度太低会丢信息,太高降维效果不明显。random_state=42保证每次跑结果一致,方便调试。拼上统计特征后,每个用户向量既有隐语义信息,又有行为统计信息,聚类时更稳。
提示:如果数据量不大,比如用户数低于 1000,可以跳过 SVD,直接用原始矩阵加统计特征,效果差别不大,还省一层调试。
3. Canopy 预处理:两个阈值 T1、T2 怎么定,粗聚类为什么能提速
3.1 Canopy 的运作逻辑和阈值选择
Canopy 的核心就两个距离阈值:T1 和 T2,且 T1 > T2。算法随机选一个点当 Canopy 中心,然后遍历所有点:距离小于 T1 的划进这个 Canopy,距离小于 T2 的从候选集里删掉,因为它已经离中心足够近,不需要再当别的 Canopy 的中心。这样一轮下来,得到一批松散的 Canopy,每个 Canopy 里的点数量不等,但都围绕一个中心。T1 决定 Canopy 的覆盖范围,T2 决定哪些点被「吸收」掉不再参与后续选中心。常见做法是 T1 取所有点对距离的均值,T2 取 T1 的一半,但具体要看数据分布。
from sklearn.metrics.pairwise import euclidean_distances def canopy_cluster(features, t1, t2): n = features.shape[0] canopies = [] # 每个 canopy 的中心索引 assigned = np.full(n, -1) # 每个点属于哪个 canopy available = set(range(n)) while available: # 随机选一个还没被吸收的点当中心 center_idx = available.pop() center = features[center_idx] distances = euclidean_distances(features, center.reshape(1, -1)).ravel() # 距离小于 T1 的划进当前 canopy in_canopy = [i for i in available if distances[i] < t1] for i in in_canopy: assigned[i] = len(canopies) available.discard(i) # 距离小于 T2 的点从候选集删除,不再当中心 for i in list(available): if distances[i] < t2: assigned[i] = len(canopies) available.discard(i) assigned[center_idx] = len(canopies) canopies.append(center_idx) return canopies, assigned这段代码里t1和t2是外部传入的,euclidean_distances算的是欧氏距离。逻辑上,先选中心,再把 T1 范围内的点划进来,然后把 T2 范围内的点从候选集删掉。注意available.discard(i)在循环里操作集合,Python 里不能在遍历集合时直接删,所以用list(available)转一下。参数上,T1 越大,Canopy 越少越大;T2 越大,被吸收的点越多,后续 K-means 的候选中心越少。我一般会先跑几组 T1、T2 看 Canopy 数量和大小分布,再定最终值。
3.2 粗聚类结果怎么交给 K-means
Canopy 跑完后,每个点要么属于某个 Canopy,要么被吸收但没明确归属。实际用的时候,通常把每个 Canopy 的中心作为 K-means 的初始中心,或者把 Canopy 数量作为 K 的参考值。这样 K-means 不用随机初始化,收敛更快,也不容易陷进局部最优。如果 Canopy 数量太多,可以合并距离近的 Canopy;如果太少,就调小 T1 重新跑。
# 假设 user_features 已经准备好 t1 = np.percentile(euclidean_distances(user_features[:500]), 60) t2 = t1 * 0.5 canopy_centers, canopy_labels = canopy_cluster(user_features, t1, t2) print(f'Canopy 数量: {len(canopy_centers)}') # 用 Canopy 中心作为 K-means 初始中心 from sklearn.cluster import KMeans kmeans = KMeans( n_clusters=len(canopy_centers), init=user_features[canopy_centers], n_init=1, random_state=42 ) kmeans.fit(user_features)这里t1用距离矩阵的 60 分位数,是个启发式做法,避免手动试太多轮。t2 = t1 * 0.5也是常见比例。KMeans的init直接传 Canopy 中心,n_init=1因为初始中心已经定了,不需要多轮随机初始化。n_clusters等于 Canopy 数量,如果 Canopy 太多导致 K 太大,可以后续用肘部法或轮廓系数再调。
注意:Canopy 阶段如果 T1 设得太大,Canopy 数量会很少,K-means 的 K 就偏小,聚类粒度太粗;T1 太小则 Canopy 太多,K-means 跑得慢。建议先用 60 分位数试,再根据轮廓系数微调。
4. 协同过滤与推荐生成:相似用户怎么找,推荐列表怎么排
4.1 用户-用户相似度计算和邻居选择
聚类完成后,每个用户有了一个群体标签。协同过滤阶段,我一般会在同一个聚类群体内找相似用户,而不是全局找,这样相似度计算量小,推荐也更贴近群体偏好。相似度用余弦相似度或皮尔逊相关系数都行,余弦对评分尺度不敏感,皮尔逊对用户评分偏置更鲁棒。豆瓣数据里用户评分尺度差异大,我倾向皮尔逊。找邻居时取 Top-N,N 一般 20 到 50,太少推荐不稳定,太多会引入噪声。
from sklearn.metrics.pairwise import cosine_similarity def recommend_for_user(user_id, matrix, labels, top_n=20, top_k=10): user_idx = matrix.index.get_loc(user_id) cluster_id = labels[user_idx] # 只取同聚类内的用户 same_cluster = np.where(labels == cluster_id)[0] cluster_matrix = matrix.iloc[same_cluster] # 算相似度 sim = cosine_similarity(cluster_matrix) sim_df = pd.DataFrame(sim, index=same_cluster, columns=same_cluster) # 找 Top-N 相似用户,排除自己 neighbors = sim_df[user_idx].drop(user_idx).nlargest(top_n).index # 加权评分:相似度乘以邻居评分 neighbor_ratings = matrix.iloc[neighbors] weights = sim_df.loc[neighbors, user_idx].values.reshape(-1, 1) weighted = (neighbor_ratings.values * weights).sum(axis=0) / weights.sum() # 排除用户已经看过的电影 seen = matrix.iloc[user_idx] > 0 scores = pd.Series(weighted, index=matrix.columns) scores = scores[~seen].nlargest(top_k) return scores这段代码先根据聚类标签筛出同群体用户,再算余弦相似度,取 Top-N 邻居,然后用相似度加权邻居评分,最后排除已看电影,取 Top-K 推荐。参数上top_n控制邻居数量,top_k控制推荐列表长度。drop(user_idx)是把自己从邻居里去掉,否则自己跟自己相似度最高,推荐会退化成热门电影。加权评分里weights.sum()做归一化,避免邻居数量不同导致分数尺度不一致。
4.2 推荐结果评估和冷启动处理
推荐生成后,得有个评估方式。常见做法是留一部分评分做测试集,算 RMSE 或 MAE,看预测评分和真实评分的差距。但聚类加协同过滤的方案,评估时要注意:如果测试用户不在任何聚类里,或者聚类内邻居太少,推荐质量会掉。冷启动用户(评分少于 5 条)我一般直接推热门电影,不走聚类和协同过滤,因为数据太少,相似度算不准。
from sklearn.metrics import mean_squared_error def evaluate(matrix, labels, test_ratio=0.2): # 简单留出法:随机选 20% 评分做测试 test_mask = np.random.rand(*matrix.shape) < test_ratio test_mask &= matrix.values > 0 # 只在有评分的位置评估 true_ratings = matrix.values[test_mask] pred_ratings = [] for i, j in zip(*np.where(test_mask)): user_id = matrix.index[i] movie_id = matrix.columns[j] # 用同聚类邻居预测该评分 cluster_id = labels[i] same_cluster = np.where(labels == cluster_id)[0] cluster_matrix = matrix.iloc[same_cluster] sim = cosine_similarity(cluster_matrix) sim_df = pd.DataFrame(sim, index=same_cluster, columns=same_cluster) neighbors = sim_df[i].drop(i).nlargest(20).index weights = sim_df.loc[neighbors, i].values ratings = matrix.iloc[neighbors][movie_id].values pred = (ratings * weights).sum() / weights.sum() if weights.sum() > 0 else 0 pred_ratings.append(pred) rmse = np.sqrt(mean_squared_error(true_ratings, pred_ratings)) return rmse评估逻辑是:随机选测试评分,对每个测试评分,用同聚类邻居加权预测,最后算 RMSE。参数test_ratio控制测试集比例,一般 0.2。weights.sum() > 0是防止邻居权重全为零导致除零。这个评估方式比较粗糙,但能快速看方案有没有大问题。如果 RMSE 明显高于全局均值的标准差,说明聚类或相似度计算有问题。
提示:冷启动用户不要硬套聚类结果,直接推热门或按电影类型推,效果更稳。聚类标签对评分少的用户本来就不准。
5. 避坑与排查:Canopy 阈值、K 值、相似度计算里的血泪经验
5.1 现象:Canopy 数量爆炸或只剩一个
原因:T1 设得太小,每个点都成了 Canopy 中心;或者 T1 设得太大,第一轮就把所有点吸收完。解决:先算距离矩阵的分位数,T1 取 50 到 70 分位数之间试,T2 取 T1 的 0.3 到 0.5 倍。跑完看 Canopy 数量,如果超过用户数的 10%,调大 T1;如果少于 5 个,调小 T1。
5.2 现象:K-means 收敛后轮廓系数很低
原因:K 值直接用了 Canopy 数量,但 Canopy 数量不一定等于最佳聚类数。解决:在 Canopy 数量附近取几个 K 值,分别跑 K-means,算轮廓系数,选最高的。或者用肘部法看惯性下降拐点。不要迷信 Canopy 数量就是 K。
5.3 现象:推荐结果全是热门电影
原因:相似度计算时没排除已看电影,或者邻居选择时热门电影被高频推荐。解决:推荐前用seen = matrix.iloc[user_idx] > 0过滤掉已看;另外可以在加权评分时对热门电影做惩罚,比如除以电影被观看次数的对数。
5.4 现象:同聚类内邻居太少,推荐报错或为空
原因:聚类粒度太细,某个群体只有几个用户,算相似度时邻居不够。解决:设一个最小邻居数,比如 5,如果同聚类内不够,就扩大到相邻聚类,或者回退到全局热门推荐。不要硬跑,否则推荐列表为空。
5.5 现象:评估 RMSE 比全局均值预测还差
原因:聚类标签和评分预测不匹配,比如把口味差异大的用户聚到了一起。解决:检查特征提取阶段是否把评分尺度差异大的用户混在一起,可以先按评分均值分桶,再在桶内聚类。另外相似度用皮尔逊代替余弦试试,皮尔逊对评分偏置更鲁棒。
6. 进阶技巧:把聚类标签当特征回灌,用增量方式更新推荐
跑通基础流程后,我一般会做一件事:把 K-means 的聚类标签当成新特征,拼回用户向量,再跑一次协同过滤。这样相似度计算时既考虑评分模式,又考虑群体归属,推荐结果会更贴。具体做法是user_features = np.hstack([user_features, kmeans.labels_.reshape(-1, 1)]),然后重新算相似度。注意标签是类别值,直接拼进去当数值用会引入虚假的距离关系,所以最好做 one-hot 编码,或者用嵌入向量代替。
另一个技巧是增量更新。用户新评分进来后,不需要全量重跑 Canopy 和 K-means,可以只更新受影响用户的向量,然后重新分配聚类标签。常见做法是维护每个聚类的中心,新用户算到各中心的距离,归到最近的类;如果某个聚类中心偏移超过阈值,再触发局部重聚类。这样推荐系统能跟上用户口味变化,不用每天全量跑。
# 增量更新:新用户归到最近聚类 def assign_new_user(new_vector, kmeans_model): distances = kmeans_model.transform(new_vector.reshape(1, -1)) return distances.argmin() # 聚类标签 one-hot 后拼回特征 from sklearn.preprocessing import OneHotEncoder enc = OneHotEncoder(sparse_output=False) labels_onehot = enc.fit_transform(kmeans.labels_.reshape(-1, 1)) user_features_aug = np.hstack([user_features, labels_onehot])assign_new_user用kmeans_model.transform算新向量到各中心的距离,取最近。OneHotEncoder把标签转成独热向量,避免数值大小引入虚假距离。sparse_output=False保证输出稠密数组,方便拼接。这套增量方案适合用户量持续增长的场景,全量重跑的成本太高。
从那以后我每次做聚类加协同过滤,都会先跑一遍 Canopy 看阈值分布,再定 K 值,最后用增量方式更新。这套流程踩过的坑基本都在上面了,希望帮到你。
本文还有配套的精品资源,点击获取