简介:这份资源面向推荐算法入门与进阶学习者,提供基于Python实现的协同过滤推荐算法参考代码,涵盖基于物品与基于用户两条技术路线,可作为课程设计、大作业、工程实训或毕设项目的起步素材。压缩包共4个文件,以2个py脚本为核心,分别对应Item_CF与User_CF两种算法实现,另含1个csv数据文件用于读取用户-物品评分样本,以及1个gitignore配置项,整体约6KB,结构精简、便于快速通读与调试。目前已有449人学习下载,说明该方向具备一定关注度。读者可借此理解相似度计算、邻居选取与评分预测的完整流程,对照代码梳理协同过滤的建模思路,并在此基础上自行调整参数、替换数据集或扩展功能,逐步掌握推荐系统的排错与优化方法。
1. 从零手写协同过滤:为什么我劝你先跑通 UserCF 再碰 ItemCF
电商详情页底下那行“买了又买”、视频 App 里“猜你喜欢”,背后最经典的一类实现就是协同过滤推荐算法。这个标题讲的是用 Python 从零实现两套最基础的推荐逻辑:基于用户的协同过滤(UserCF)和基于物品的协同过滤(ItemCF)。它解决的是没有深度学习、没有特征工程时,如何仅凭一张用户-物品评分表,把“相似的人喜欢的东西”或“相似的东西被同一批人喜欢”算出来。适合刚学完 Python 基础语法、想找一个能跑通、能改参数、能看见中间结果的入门项目的人。我见过太多人一上来就抄 sklearn 或 surprise 的调用,结果连相似度矩阵长什么样都没见过,调参全靠玄学。这篇笔记按“先立住原理、再动手复现、最后踩坑”的顺序走,代码全部可抄,数据用公开的 MovieLens 小样本,本地 Python 环境就能跑。
2. 数据准备与相似度计算:把评分表变成可计算的矩阵
2.1 为什么选 MovieLens 而不是自己造数据
协同过滤对数据分布很敏感。自己随手编的评分表往往太规整,跑出来的相似度全是 1 或 0,看不出问题。常见做法是用 MovieLens 的 ml-latest-small,约 10 万条评分、600 多个用户、9000 多部电影,规模刚好能在单机内存里跑完,又保留了真实数据的稀疏性。下载后得到 ratings.csv 和 movies.csv 两个文件,前者是 userId、movieId、rating、timestamp 四列,后者是 movieId、title、genres。我一般会先把它读进 pandas,做一次基本清洗:去掉评分次数少于 5 次的用户和少于 5 次的物品,否则相似度计算会被长尾噪声带偏。
import pandas as pd import numpy as np # 读取评分数据,只保留必要列 ratings = pd.read_csv('ml-latest-small/ratings.csv', usecols=['userId', 'movieId', 'rating']) # 过滤低频用户和低频物品,阈值 5 是经验值,可调 user_counts = ratings['userId'].value_counts() item_counts = ratings['movieId'].value_counts() ratings = ratings[ratings['userId'].isin(user_counts[user_counts >= 5].index)] ratings = ratings[ratings['movieId'].isin(item_counts[item_counts >= 5].index)] print(f"清洗后评分条数: {len(ratings)}") print(f"用户数: {ratings['userId'].nunique()}, 物品数: {ratings['movieId'].nunique()}")这段代码的逻辑是先统计每个用户和每个物品的评分次数,再用布尔索引把低频的过滤掉。阈值 5 不是固定的,数据量大可以提到 10,数据量小降到 3。注意过滤后要重新检查一遍,因为删用户可能让某些物品的评分次数掉到阈值以下,严格做法是循环过滤直到稳定,但入门阶段跑一轮就够了。
2.2 用户-物品矩阵与两种相似度的选择
协同过滤的核心是把数据整理成用户-物品评分矩阵,行是用户、列是物品,格子里的值就是评分,没评过的位置是 NaN。UserCF 算的是行与行之间的相似度,ItemCF 算的是列与列之间的相似度。相似度度量常见的有三种:余弦相似度、皮尔逊相关系数、调整余弦相似度。余弦相似度对评分绝对值不敏感,适合评分尺度不统一的场景;皮尔逊会减去各自均值,能抵消用户打分偏严或偏松的习惯。我一般先用余弦跑通,再换皮尔逊对比效果。
# 构建用户-物品评分矩阵 user_item = ratings.pivot_table(index='userId', columns='movieId', values='rating') # 用 0 填充缺失值,仅用于余弦相似度计算 matrix = user_item.fillna(0).values # 归一化:减去每个用户的平均评分,缓解打分尺度差异 user_mean = np.true_divide(matrix.sum(axis=1), (matrix != 0).sum(axis=1)) matrix_norm = matrix.copy() for i in range(matrix.shape[0]): mask = matrix[i] != 0 matrix_norm[i, mask] = matrix[i, mask] - user_mean[i] # 余弦相似度:向量点积除以模长乘积 def cosine_sim(a, b): dot = np.dot(a, b) norm = np.linalg.norm(a) * np.linalg.norm(b) return dot / norm if norm != 0 else 0 # 计算用户相似度矩阵(示例取前 100 个用户,避免全量太慢) n_users = 100 user_sim = np.zeros((n_users, n_users)) for i in range(n_users): for j in range(i + 1, n_users): s = cosine_sim(matrix_norm[i], matrix_norm[j]) user_sim[i, j] = user_sim[j, i] = s这里有几个参数要留意。填充 0 是为了让余弦相似度能算,但 0 本身有“评分为 0”的歧义,所以后面做了去均值归一化,把 0 位置重新置零再减均值,避免未评分被当成低分。n_users 设 100 只是演示,全量用户两两计算是 O(n²),600 个用户大概 18 万次计算,Python 循环能扛住,上万用户就必须上矩阵运算或分块。相似度矩阵是对称的,算一半填两半,省一半时间。
3. 基于用户的协同过滤:找相似的人,推他喜欢的物
3.1 UserCF 的预测公式与邻居选择
UserCF 的直觉是:如果 A 和 B 都给《肖申克的救赎》《教父》打了高分,那 B 给《低俗小说》打的高分,A 大概率也喜欢。预测用户 u 对物品 i 的评分,就是找 u 的 K 个最相似用户,用他们的评分加权平均。权重是相似度,评分要减去各自均值再加回 u 的均值,这样能抵消不同用户打分基准的差异。公式写成:pred(u,i) = mean(u) + Σ(sim(u,v) * (rating(v,i) - mean(v))) / Σ|sim(u,v)|,其中 v 是给 i 评过分的邻居。
def predict_usercf(u_idx, i_idx, user_sim, matrix, user_mean, k=20): # 找到给该物品评过分的用户 rated_users = np.where(matrix[:, i_idx] != 0)[0] if len(rated_users) == 0: return user_mean[u_idx] # 没人评过,退回均值 # 按相似度排序取前 k 个 sims = [(v, user_sim[u_idx, v]) for v in rated_users if v != u_idx] sims.sort(key=lambda x: x[1], reverse=True) top_k = sims[:k] # 加权平均 numerator = 0.0 denominator = 0.0 for v, sim in top_k: numerator += sim * (matrix[v, i_idx] - user_mean[v]) denominator += abs(sim) if denominator == 0: return user_mean[u_idx] return user_mean[u_idx] + numerator / denominatork 取 20 是常见起点,太小容易受个别邻居噪声影响,太大则把不相似的人也拉进来稀释信号。实际调参时我会在 10、20、40、80 之间跑对比,看 RMSE 或 Precision@N 的变化。注意 rated_users 里要排除用户自己,否则自己给自己推荐没有意义。denominator 用绝对值累加,是因为相似度可能为负,负相似度表示口味相反,加权时方向要正确。
3.2 生成 Top-N 推荐与离线评估
预测评分只是中间步骤,最终要输出每个用户没看过且预测分最高的 N 个物品。评估用留一法:每个用户随机留一个评分做测试集,其余做训练集,看推荐列表里有没有命中这个物品。指标用 Hit Rate 和 NDCG,前者看命中率,后者看命中位置是否靠前。
def recommend_usercf(u_idx, user_sim, matrix, user_mean, n=10, k=20): # 只对用户没评过分的物品预测 unrated = np.where(matrix[u_idx] == 0)[0] preds = [(i, predict_usercf(u_idx, i, user_sim, matrix, user_mean, k)) for i in unrated] preds.sort(key=lambda x: x[1], reverse=True) return [i for i, _ in preds[:n]] # 留一法评估示例 hit = 0 total = 0 for u in range(50): # 取前 50 个用户演示 rated = np.where(matrix[u] != 0)[0] if len(rated) < 2: continue test_item = np.random.choice(rated) # 临时把测试物品置零,模拟未评分 saved = matrix[u, test_item] matrix[u, test_item] = 0 recs = recommend_usercf(u, user_sim, matrix, user_mean, n=10) if test_item in recs: hit += 1 total += 1 matrix[u, test_item] = saved # 恢复 print(f"Hit Rate@10: {hit / total:.4f}")留一法要注意恢复现场,否则下一轮评估会用到被置零的数据。Hit Rate 在 MovieLens 小样本上通常能到 0.1 到 0.2 之间,低于 0.05 说明相似度计算或邻居选择有问题。这个评估方式比较粗糙,但足够判断代码是否跑通、参数是否合理。
4. 基于物品的协同过滤:从物品相似度到推荐列表
4.1 ItemCF 与 UserCF 的本质差异
ItemCF 算的是物品之间的相似度:如果喜欢《星球大战》的人也喜欢《帝国反击战》,那这两部电影就相似。预测用户对物品的评分时,找用户已经评过分的、与目标物品最相似的 K 个物品,用这些物品的评分加权。和 UserCF 比,ItemCF 的相似度矩阵更稳定——物品的数量和属性变化比用户慢,所以工业界在电商场景更偏爱 ItemCF。但 ItemCF 有个前提:用户的历史行为要足够多,否则找不到足够的相似物品来支撑预测。
# 物品相似度矩阵,转置后按列计算 item_matrix = matrix_norm.T # 形状变为 物品数 x 用户数 n_items = 100 # 演示取前 100 个物品 item_sim = np.zeros((n_items, n_items)) for i in range(n_items): for j in range(i + 1, n_items): s = cosine_sim(item_matrix[i], item_matrix[j]) item_sim[i, j] = item_sim[j, i] = s物品相似度计算和用户相似度结构一样,只是把矩阵转置。注意这里用的是归一化后的矩阵,去均值是按物品维度做的,也就是减去每个物品的平均评分。实际工程中还会对相似度做惩罚:热门物品容易和所有物品都相似,所以会除以物品流行度的对数,降低热门物品的权重。
4.2 ItemCF 预测与推荐生成
ItemCF 的预测公式:pred(u,i) = Σ(sim(i,j) * rating(u,j)) / Σ|sim(i,j)|,其中 j 是用户 u 评过分的、与 i 最相似的物品。这里不需要加用户均值,因为物品相似度已经隐含了评分尺度信息。推荐生成时同样排除用户已评分的物品。
def predict_itemcf(u_idx, i_idx, item_sim, matrix, k=20): # 用户评过分的物品 rated_items = np.where(matrix[u_idx] != 0)[0] if len(rated_items) == 0: return 0 # 找与目标物品最相似的 k 个已评分物品 sims = [(j, item_sim[i_idx, j]) for j in rated_items if j < item_sim.shape[0] and j != i_idx] sims.sort(key=lambda x: x[1], reverse=True) top_k = sims[:k] numerator = 0.0 denominator = 0.0 for j, sim in top_k: numerator += sim * matrix[u_idx, j] denominator += abs(sim) return numerator / denominator if denominator != 0 else 0 def recommend_itemcf(u_idx, item_sim, matrix, n=10, k=20): unrated = np.where(matrix[u_idx] == 0)[0] unrated = [i for i in unrated if i < item_sim.shape[0]] preds = [(i, predict_itemcf(u_idx, i, item_sim, matrix, k)) for i in unrated] preds.sort(key=lambda x: x[1], reverse=True) return [i for i, _ in preds[:n]]k 的含义和 UserCF 一致,控制参与加权的相似物品数量。ItemCF 的 k 通常比 UserCF 小,因为物品相似度更集中,10 到 20 就够。如果用户评分物品很少,比如只有两三个,ItemCF 会退化,这时候要么退回热门推荐,要么用 UserCF 兜底。实际系统里两种算法经常混用,按用户活跃度分流。
5. 避坑与排查:协同过滤跑不通时先看这五条
5.1 相似度全是 0 或全是 1
现象:打印相似度矩阵,发现非对角元素要么接近 0,要么全是 1。原因通常是数据没有归一化,或者填充 0 之后向量太稀疏,余弦相似度被大量 0 主导。解决:先做去均值归一化,再检查每个向量的非零元素个数,少于 3 个的用户或物品直接过滤掉。如果还是全 1,检查是不是把同一个向量复制了两遍。
5.2 推荐结果全是热门物品
现象:不管给谁推荐,Top-N 里都是那几部最热门的电影。原因是没有对热门物品做惩罚,相似度计算时热门物品和谁都像。解决:在相似度分母上加流行度惩罚项,比如 sim(i,j) / log(1 + |N(i)|),其中 |N(i)| 是物品 i 的评分人数。或者在推荐排序时对热门物品降权。
5.3 预测评分超出合理范围
现象:预测出 8.5 分,但数据集评分范围是 0.5 到 5。原因是加权平均时没有限制边界,或者相似度有负值导致分子异常。解决:预测后做 clip,把结果截断到 [min_rating, max_rating]。更根本的是检查相似度是否出现负值,负相似度在推荐里通常没有意义,可以直接置零。
5.4 评估指标低得离谱
现象:Hit Rate@10 只有 0.01。原因可能是留一法把测试物品置零后,该物品的相似度计算也受影响,导致推荐时找不到它。解决:评估时用独立的训练集和测试集,不要在同一份矩阵上又训练又测试。或者用时间切分,用早期数据训练、后期数据测试,更接近真实场景。
5.5 计算太慢跑不完
现象:600 个用户两两算相似度要几分钟,上万用户直接卡死。原因是 Python 双重循环是 O(n²)。解决:用 numpy 的矩阵运算替代循环,比如用 sklearn 的 cosine_similarity 一次性算完;或者用稀疏矩阵,只存非零元素;再不行就上 Faiss 或 Annoy 做近似最近邻。入门阶段先用小样本跑通,别一上来就全量。
6. 把两套算法装进一个可调参的评估脚本
跑通单次预测之后,真正有价值的是能快速对比 UserCF 和 ItemCF 在不同 k 值下的表现。我一般会写一个统一入口,把数据加载、矩阵构建、相似度计算、推荐生成、评估串起来,用命令行参数控制算法类型和 k 值。这样调参不用改代码,跑一批实验就能看出趋势。
import argparse def build_sim(matrix_norm, mode='user'): if mode == 'user': target = matrix_norm else: target = matrix_norm.T n = min(100, target.shape[0]) sim = np.zeros((n, n)) for i in range(n): for j in range(i + 1, n): s = cosine_sim(target[i], target[j]) sim[i, j] = sim[j, i] = s return sim def evaluate(mode, k, n=10): sim = build_sim(matrix_norm, mode) hit, total = 0, 0 for u in range(50): rated = np.where(matrix[u] != 0)[0] if len(rated) < 2: continue test_item = np.random.choice(rated) saved = matrix[u, test_item] matrix[u, test_item] = 0 if mode == 'user': recs = recommend_usercf(u, sim, matrix, user_mean, n, k) else: recs = recommend_itemcf(u, sim, matrix, n, k) if test_item in recs: hit += 1 total += 1 matrix[u, test_item] = saved return hit / total if total > 0 else 0 if __name__ == '__main__': parser = argparse.ArgumentParser() parser.add_argument('--mode', choices=['user', 'item'], default='user') parser.add_argument('--k', type=int, default=20) args = parser.parse_args() score = evaluate(args.mode, args.k) print(f"mode={args.mode}, k={args.k}, HitRate@10={score:.4f}")这个脚本的关键是把 mode 和 k 暴露成参数,跑python cf.py --mode user --k 40就能切换。实测下来,MovieLens 小样本上 UserCF 的 k 在 20 到 40 之间 Hit Rate 比较稳,ItemCF 的 k 在 10 到 20 之间更好。k 再往上加,指标不升反降,因为引入了不相似的邻居。我自己的习惯是先把 k 从 5 扫到 100,画一条曲线,看拐点在哪,再在那个区间细调。另一个容易忽略的点是随机种子,留一法选测试物品用了 np.random.choice,不固定种子的话每次结果会飘,对比实验前先 np.random.seed(42)。这套代码离工业级还差得远,但作为理解协同过滤的脚手架,改一改就能接自己的数据。希望帮到你。
本文还有配套的精品资源,点击获取