ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

从零手写协同过滤:Python实现UserCF与ItemCF推荐算法

2026/10/3 9:09:19 拓冰建站 浏览量
从零手写协同过滤:Python实现UserCF与ItemCF推荐算法 简介这份资源面向推荐算法入门与进阶学习者提供基于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(indexuserId, columnsmovieId, valuesrating) # 用 0 填充缺失值仅用于余弦相似度计算 matrix user_item.fillna(0).values # 归一化减去每个用户的平均评分缓解打分尺度差异 user_mean np.true_divide(matrix.sum(axis1), (matrix ! 0).sum(axis1)) 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, k20): # 找到给该物品评过分的用户 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(keylambda x: x[1], reverseTrue) 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 或 PrecisionN 的变化。注意 rated_users 里要排除用户自己否则自己给自己推荐没有意义。denominator 用绝对值累加是因为相似度可能为负负相似度表示口味相反加权时方向要正确。3.2 生成 Top-N 推荐与离线评估预测评分只是中间步骤最终要输出每个用户没看过且预测分最高的 N 个物品。评估用留一法每个用户随机留一个评分做测试集其余做训练集看推荐列表里有没有命中这个物品。指标用 Hit Rate 和 NDCG前者看命中率后者看命中位置是否靠前。def recommend_usercf(u_idx, user_sim, matrix, user_mean, n10, k20): # 只对用户没评过分的物品预测 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(keylambda x: x[1], reverseTrue) 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, n10) if test_item in recs: hit 1 total 1 matrix[u, test_item] saved # 恢复 print(fHit Rate10: {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, k20): # 用户评过分的物品 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(keylambda x: x[1], reverseTrue) 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, n10, k20): 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(keylambda x: x[1], reverseTrue) 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 Rate10 只有 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, modeuser): 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, n10): 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], defaultuser) parser.add_argument(--k, typeint, default20) args parser.parse_args() score evaluate(args.mode, args.k) print(fmode{args.mode}, k{args.k}, HitRate10{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)。这套代码离工业级还差得远但作为理解协同过滤的脚手架改一改就能接自己的数据。希望帮到你。本文还有配套的精品资源点击获取