资讯动态

从零手写协同过滤推荐系统:基于NumPy的UserCF/ItemCF源码实现

发布时间:2026/9/5 20:39:49 来源:尧图企业网站定制
简介这是一份面向Python初学者与推荐系统入门者的开源实践资源聚焦协同过滤、矩阵分解、图模型等主流算法的原理实现与工程落地。资源包含完整项目结构data目录提供MovieLens等测试数据集py3.x目录下涵盖ItemCF/UserCF含sklearn与纯Python双版本、LFM、Graph-Based等21个可运行Python脚本spark目录提供Scala实现与特征工程模块manual目录整合基础知识、论文精读与架构设计说明。压缩包共70个文件以21个.py源码、10个.md文档、5个.csv数据、6个.scala及2个.parquet为核心总大小18.12MB结构清晰、模块解耦便于逐层理解算法逻辑与系统集成。已有3580人学习下载读者可直接复现经典推荐流程——从数据录入、用户/物品特征生成、多算法推荐、过滤排序到评价指标计算并延伸至LDA、TagCF、ALS及深度学习模型等前沿方向是少有的兼顾理论推导、代码实现与系统架构的全栈式学习材料。1. 项目概述从零构建一个可运行的推荐系统如果你对Python有一定了解想亲手搭建一个能实际跑起来的推荐系统那么你来对地方了。推荐系统听起来高大上像是大厂才玩得转的技术但其实它的核心思想并不复杂。简单来说它就是一套程序能根据用户过去的行为比如点击、购买、评分预测他未来可能喜欢什么然后把预测结果“推荐”给他。我们日常用的电商、视频、音乐App背后都离不开它。这个项目的目标很明确不依赖任何现成的推荐系统框架比如Surprise、LightFM完全从零开始用最基础的Python和数学库实现一个最经典的协同过滤推荐算法并把它封装成一个可以调用的模块。为什么要从零开始因为只有亲手实现一遍你才能真正理解算法内部的矩阵运算、相似度计算、评分预测是怎么一步步完成的而不是当一个只会调API的“调包侠”。这对于理解推荐系统的本质以及后续应对更复杂的业务场景比如冷启动、实时更新至关重要。整个项目会围绕“用户-物品评分矩阵”这个核心概念展开。我们会用Python的NumPy来处理矩阵运算用Pandas来管理数据最终实现基于用户的协同过滤UserCF和基于物品的协同过滤ItemCF。我会带你走过数据模拟、相似度计算、评分预测、Top-N推荐的全流程并分享我在实现过程中踩过的坑和优化技巧。无论你是想丰富自己的项目经历还是为面试做准备这个从源码级入手的实践都能给你带来扎实的收获。2. 核心原理与方案选型为什么是协同过滤在动手写代码之前我们必须搞清楚要做什么以及为什么这么做。推荐算法流派很多有基于内容的、协同过滤的、深度学习的等等。对于入门和实践来说协同过滤Collaborative Filtering, CF是最经典、最直观也最适合教学实现的选择。2.1 协同过滤的两种基本思想协同过滤的核心假设是“物以类聚人以群分”。它主要分为两大类基于用户的协同过滤UserCF给用户A推荐物品思路是找到和A兴趣相似的一群用户邻居然后把邻居们喜欢而A没看过的物品推荐给A。它的哲学是“和你口味相似的人喜欢的东西你可能也喜欢”。基于物品的协同过滤ItemCF给用户A推荐物品思路是找到A历史上喜欢的物品然后推荐与这些物品相似的其他物品。它的哲学是“你喜欢这个物品那么和它相似的物品你可能也喜欢”。为什么我们选择协同过滤作为第一个实现目标数据要求相对简单它只需要用户对物品的“行为数据”如评分、点击而不需要物品的内容特征如电影的导演、演员或用户的画像数据如年龄、性别。这降低了数据准备的难度。原理易于理解相似度计算、邻居选取、评分预测这些步骤都有直观的数学对应如余弦相似度、皮尔逊相关系数非常适合用代码来具象化。效果经久不衰尽管深度学习很火但协同过滤及其变种如矩阵分解仍然是工业界的基础组件之一理解它是进阶的必经之路。2.2 技术栈选型极简主义我们的原则是用最少的、最核心的库完成所有功能。避免引入复杂的框架以保证代码的透明性和可控性。NumPy这是我们的绝对核心。所有用户-物品评分矩阵都会被表示为NumPy的二维数组ndarray。矩阵的切片、转置、加减乘除、求和等操作都将依赖NumPy高效完成。手动用Python循环去算矩阵那会慢得让你怀疑人生。Pandas主要用于初始数据的加载、清洗和初步观察。它提供了方便的DataFrame结构来查看数据。但在核心算法计算中我们会将DataFrame转换为NumPy数组以提升性能。SciPy可选它的spatial.distance模块提供了直接计算余弦相似度等函数比我们自己写的循环更高效。但在教学实现中我建议先自己手写一遍相似度计算理解其原理后续再替换为SciPy的优化版本。标准库math, collections用于一些基本的数学运算和数据结构管理。不选用现成框架如Surprise的原因正如开头所说本项目目标是“源码级”理解。使用框架虽然能快速得到结果但就像开自动挡汽车你并不知道引擎如何工作。自己实现一遍你才能知道算法每个环节的输入输出、可能遇到的问题如稀疏矩阵、相似度归一化这是框架无法给予的深度。3. 数据准备与核心数据结构设计任何推荐系统都始于数据。我们没有真实的生产数据所以需要自己构造一个仿真的数据集。这反而更好因为你可以完全控制数据的规模和特性方便调试和验证。3.1 模拟用户-物品评分数据我们模拟一个简单的场景有6个用户User0-User5和8部电影Item0-Item7。评分范围是1-5分分数越高表示越喜欢。很多用户并没有对所有电影评分这就构成了一个稀疏矩阵。import numpy as np import pandas as pd # 模拟用户-物品评分矩阵 (6 users, 8 items) # 行代表用户列代表物品。NaN表示该用户未对该物品评分。 ratings_data { User0: [5, 3, 4, 4, None, None, 2, 1], User1: [3, 1, 2, 3, 3, 2, 1, 5], User2: [4, 3, 4, 3, 5, 4, 3, 2], User3: [3, 3, 1, 5, 4, 5, 2, 3], User4: [1, 5, 5, 2, 1, 1, 5, 5], User5: [2, 4, None, 1, 2, 4, 4, 4] } df_ratings pd.DataFrame(ratings_data, index[fItem{i} for i in range(8)]).T print(原始评分矩阵DataFrame视图:) print(df_ratings) print(\n矩阵形状:, df_ratings.shape)这个DataFrame很直观但为了计算我们需要将其转换为NumPy数组。这里有一个关键处理如何对待缺失值NaN在协同过滤中我们通常只对共同评分的项目计算相似度。因此在计算阶段我们需要能够忽略NaN。一种常见做法是先将其转换为0但在计算相似度时通过掩码mask来排除这些零值的影响。更稳妥的方法是在计算两个用户或物品的相似度时只取出他们共同评分的项组成向量进行计算。3.2 核心类设计UserCFRecommender我们将把基于用户的协同过滤算法封装成一个类。这样做的好处是状态清晰模型参数、评分矩阵都作为类属性并且可以方便地实现训练拟合数据和预测两个步骤。类的核心结构设计如下__init__: 初始化设置相似度度量方法如‘cosine’, ‘pearson’、邻居数量k等参数。fit: 接收评分矩阵计算并存储所有用户两两之间的相似度矩阵。_compute_similarity: 一个内部方法根据选择的方法计算两个用户向量之间的相似度。predict: 预测指定用户对指定物品的评分。recommend: 为指定用户生成Top-N的推荐物品列表。注意事项相似度矩阵的存储用户相似度矩阵是一个n_users * n_users的对称矩阵对角线元素为1自己与自己的相似度。当用户数量很大时比如上百万这个矩阵会变得极其庞大无法全部存储在内存中。在工业级系统中通常只存储每个用户的Top-K个最近邻使用稀疏矩阵存储格式或者使用基于集群的分布式算法。在我们的教学实现中因为数据量小我们可以计算并存储全量相似度矩阵但你必须意识到这是第一个可能遇到的可扩展性瓶颈。4. 核心算法实现手撕UserCF现在让我们进入最核心的部分一步步实现UserCF。4.1 相似度计算算法的基石相似度衡量了两个用户兴趣的接近程度。最常用的有两种余弦相似度Cosine Similarity将用户评分看作向量计算向量夹角的余弦值。它只考虑向量的方向不考虑长度即评分尺度。公式为cos(u, v) (u·v) / (||u|| * ||v||)。优点计算简单对绝对值不敏感。缺点没有考虑用户评分偏置比如有的用户习惯打高分有的习惯打低分。皮尔逊相关系数Pearson Correlation衡量两个向量之间的线性相关性。它先减去各自向量的平均值再计算余弦相似度。公式为pearson(u, v) Σ[(u_i - u_mean)*(v_i - v_mean)] / (std_u * std_v)。优点能消除用户评分偏置的影响更关注评分趋势的相对性。缺点当共同评分的项目很少时计算可能不稳定。在我们的实现中我们将重点实现皮尔逊相关系数因为它更常用也更能体现协同过滤“去偏置”的思想。同时我们必须处理NaN值。class UserCFRecommender: def __init__(self, similarity_metricpearson, k3): 初始化推荐器 :param similarity_metric: 相似度度量pearson 或 cosine :param k: 选取的最近邻数量 self.similarity_metric similarity_metric self.k k self.ratings_matrix None # 评分矩阵 (n_users, n_items) self.user_sim_matrix None # 用户相似度矩阵 (n_users, n_users) self.user_ids None self.item_ids None def fit(self, ratings_df): 训练模型计算用户相似度矩阵 self.user_ids ratings_df.index.tolist() self.item_ids ratings_df.columns.tolist() # 将DataFrame转换为NumPy数组NaN转换为0后续计算中会通过共同评分项掩码处理 self.ratings_matrix ratings_df.fillna(0).values.astype(float) n_users len(self.user_ids) self.user_sim_matrix np.zeros((n_users, n_users)) for i in range(n_users): for j in range(i, n_users): # 利用对称性减少计算量 if i j: sim 1.0 else: sim self._compute_similarity(i, j) self.user_sim_matrix[i, j] sim self.user_sim_matrix[j, i] sim # 对称矩阵 print(f用户相似度矩阵计算完成。形状: {self.user_sim_matrix.shape}) def _compute_similarity(self, user_i_idx, user_j_idx): 计算两个用户之间的相似度皮尔逊相关系数 # 获取两个用户的评分向量 ratings_i self.ratings_matrix[user_i_idx] ratings_j self.ratings_matrix[user_j_idx] # 找到共同评分的项目索引即两个向量都不为0的位置因为我们用0填充了NaN # 注意在实际中0可能是真实评分所以更好的做法是额外维护一个“评分是否有效”的掩码矩阵。 # 这里为简化假设0均为缺失值。更严谨的做法是传入原始的DataFrame并处理NaN。 common_idx np.where((ratings_i ! 0) (ratings_j ! 0))[0] if len(common_idx) 2: # 共同评分项目太少相似度不可信返回0 return 0.0 vec_i ratings_i[common_idx] vec_j ratings_j[common_idx] if self.similarity_metric cosine: # 余弦相似度 dot_product np.dot(vec_i, vec_j) norm_i np.linalg.norm(vec_i) norm_j np.linalg.norm(vec_j) if norm_i 0 or norm_j 0: return 0.0 return dot_product / (norm_i * norm_j) elif self.similarity_metric pearson: # 皮尔逊相关系数 mean_i, mean_j np.mean(vec_i), np.mean(vec_j) dev_i, dev_j vec_i - mean_i, vec_j - mean_j numerator np.dot(dev_i, dev_j) denom_i, denom_j np.linalg.norm(dev_i), np.linalg.norm(dev_j) if denom_i 0 or denom_j 0: return 0.0 return numerator / (denom_i * denom_j) else: raise ValueError(f不支持的相似度度量: {self.similarity_metric})实操心得共同评分项的处理上面代码中用ratings ! 0来判断共同评分项这在我们用0填充NaN的假设下是可行的但不够健壮。更标准的做法是在fit阶段除了ratings_matrix再维护一个布尔矩阵mask_matrix标记哪些是真实评分True哪些是缺失值False。在计算相似度时使用这个掩码来提取共同有效的评分项。这能避免真实评分为0带来的误判。为了代码清晰本例暂用简化版但你在实际项目或面试中需要意识到这一点并说明。4.2 评分预测加权平均的艺术计算完相似度后我们就可以预测用户u对物品i的评分了。公式是UserCF的核心预测评分(u, i) u的平均分 Σ [相似度(u, v) * (v对i的评分 - v的平均分)] / Σ |相似度(u, v)|这个公式可以拆解理解u的平均分这是用户u的基准分。v对i的评分 - v的平均分这是邻居用户v对物品i的“偏好程度”高于或低于其平均水平。相似度(u, v)作为权重越相似的用户其偏好对预测的影响越大。最后除以相似度绝对值的和是为了进行归一化。求和只针对那些对物品i有过评分的、且是u的Top-K个最近邻的用户v进行。def predict(self, user_id, item_id, verboseFalse): 预测指定用户对指定物品的评分 if user_id not in self.user_ids or item_id not in self.item_ids: raise ValueError(用户ID或物品ID不在训练集中) u_idx self.user_ids.index(user_id) i_idx self.item_ids.index(item_id) # 如果该用户已经对该物品有评分非0则直接返回原评分在实际中我们可能想预测缺失值 if self.ratings_matrix[u_idx, i_idx] ! 0: if verbose: print(f用户 {user_id} 已对物品 {item_id} 评分: {self.ratings_matrix[u_idx, i_idx]}) return self.ratings_matrix[u_idx, i_idx] # 步骤1找到用户u的Top-K个最近邻排除自己 # 获取用户u对所有其他用户的相似度向量 sim_vector self.user_sim_matrix[u_idx].copy() sim_vector[u_idx] -np.inf # 排除自己 # 获取相似度最高的K个邻居的索引 top_k_neighbor_indices np.argsort(sim_vector)[-self.k:][::-1] # 从大到小排序 # 步骤2计算用户u的平均评分仅基于其已评分的项目 u_rated_mask self.ratings_matrix[u_idx] ! 0 u_mean_rating np.mean(self.ratings_matrix[u_idx][u_rated_mask]) if np.any(u_rated_mask) else 0 numerator 0.0 denominator 0.0 for v_idx in top_k_neighbor_indices: # 邻居用户v对物品i的评分 rating_vi self.ratings_matrix[v_idx, i_idx] if rating_vi 0: # 邻居v未评价物品i跳过 continue # 计算邻居用户v的平均评分 v_rated_mask self.ratings_matrix[v_idx] ! 0 v_mean_rating np.mean(self.ratings_matrix[v_idx][v_rated_mask]) if np.any(v_rated_mask) else 0 sim_uv sim_vector[v_idx] # 用户u和v的相似度 numerator sim_uv * (rating_vi - v_mean_rating) denominator np.abs(sim_uv) if denominator 0: # 没有找到任何对物品i有评分的有效邻居退回全局平均或用户平均 predicted u_mean_rating if u_mean_rating 0 else np.mean(self.ratings_matrix[self.ratings_matrix ! 0]) if verbose: print(f警告无法找到有效邻居进行预测退回平均值: {predicted:.2f}) else: predicted u_mean_rating numerator / denominator # 将预测评分截断到评分范围例如1-5分 predicted max(1.0, min(5.0, predicted)) if verbose: print(f预测用户 {user_id} 对物品 {item_id} 的评分为: {predicted:.2f}) print(f 使用的邻居用户: {[self.user_ids[idx] for idx in top_k_neighbor_indices]}) return predicted注意事项分母为零与冷启动问题上面的代码处理了denominator 0的情况这在推荐系统中非常常见被称为“冷启动”或“稀疏性”问题。当目标物品非常冷门或者目标用户非常独特找不到相似邻居时算法就会失效。我们的降级策略是退回该用户的平均分如果用户平均分也为0新用户则退回全局平均分。在实际系统中会有更复杂的策略如结合基于内容的推荐、流行度推荐等。4.3 生成Top-N推荐预测单个评分不是最终目的我们的目标是为用户生成一个他可能最感兴趣的、尚未交互的物品列表Top-N推荐。def recommend(self, user_id, n3, verboseFalse): 为用户生成Top-N推荐物品列表 u_idx self.user_ids.index(user_id) recommendations [] # 遍历所有物品 for i_idx, item_id in enumerate(self.item_ids): # 只推荐用户未评分的物品 if self.ratings_matrix[u_idx, i_idx] ! 0: continue pred_rating self.predict(user_id, item_id, verboseFalse) recommendations.append((item_id, pred_rating)) # 按预测评分从高到低排序取前N个 recommendations.sort(keylambda x: x[1], reverseTrue) top_n recommendations[:n] if verbose: print(f为用户 {user_id} 生成的 Top-{n} 推荐:) for item, score in top_n: print(f 物品 {item}: 预测评分 {score:.2f}) return top_n5. 项目运行、评估与效果分析让我们把上面的代码整合起来看看这个推荐系统效果如何。5.1 完整流程演示# 1. 初始化推荐器使用皮尔逊相似度找3个邻居 recommender UserCFRecommender(similarity_metricpearson, k3) # 2. 训练模型计算相似度矩阵 recommender.fit(df_ratings) # 3. 查看用户相似度矩阵部分 print(\n用户相似度矩阵前4行:) print(recommender.user_sim_matrix[:4, :4].round(3)) # 4. 进行预测 print(\n--- 单点预测测试 ---) target_user User0 target_item Item4 # User0未评分Item4 pred recommender.predict(target_user, target_item, verboseTrue) # 5. 生成推荐列表 print(f\n--- 为 {target_user} 生成推荐 ---) top_recommendations recommender.recommend(target_user, n3, verboseTrue)运行上述代码你会看到控制台输出相似度矩阵、预测评分和推荐列表。你可以尝试改变k值邻居数或相似度度量方法cosine观察推荐结果的变化。5.2 如何评估推荐系统的效果我们造了数据也输出了结果但怎么知道推荐得好不好呢在真实场景中我们需要有“标准答案”来评估。通常我们会将历史数据分为训练集和测试集。划分数据例如将每个用户的评分随机隐藏一部分比如20%作为测试集剩下的作为训练集。在训练集上训练用训练集数据计算用户相似度。在测试集上预测对于测试集中的每一个“用户-物品”对用我们的模型预测其评分。计算误差将预测评分与真实评分比较。最常用的指标是均方根误差RMSE和平均绝对误差MAE。值越小预测越准。RMSE sqrt( Σ(预测值-真实值)^2 / N )MAE Σ|预测值-真实值| / NTop-N推荐评估对于推荐列表我们更关心“是否推荐了用户真正喜欢的物品”。常用指标有准确率PrecisionN推荐的N个物品中有多少是用户真正喜欢的在测试集中评分高的Precision #(推荐且喜欢的) / N召回率RecallN用户所有喜欢的物品中有多少被推荐出来了Recall #(推荐且喜欢的) / #(用户总喜欢的)由于我们的数据是模拟的且量小进行严格的训练/测试划分意义不大但你必须理解这个评估流程。在实际项目中这是衡量算法好坏、进行A/B测试的黄金标准。实操心得离线评估的局限性离线评估用历史数据划分测试集虽然重要但它无法完全模拟线上真实环境。比如它无法评估推荐系统对用户长期兴趣的影响、无法捕捉推荐带来的惊喜性Serendipity和多样性Diversity。因此一个成熟的推荐系统最终一定要经过线上A/B测试的检验核心指标可能是点击率CTR、转化率、停留时长等业务指标。6. 从UserCF到ItemCF思路迁移与实现差异实现了UserCF之后ItemCF就很容易理解了。它们的核心步骤一模一样1. 构建矩阵UserCF是用户-物品ItemCF是物品-用户其实就是原矩阵的转置2. 计算相似度UserCF算用户间相似度ItemCF算物品间相似度3. 预测评分公式类似权重变成物品相似度基准变成物品平均分。你可以尝试自己实现一个ItemCFRecommender类。这里给出最关键的不同点fit方法计算的是物品相似度矩阵item_sim_matrix形状为(n_items, n_items)。预测公式ItemCF预测评分(u, i) i的平均分 Σ [相似度(i, j) * (u对j的评分 - j的平均分)] / Σ |相似度(i, j)|求和是针对用户u评分过的、且与物品i最相似的Top-K个物品j进行的。UserCF vs ItemCF 如何选择UserCF适用于用户数量相对较少、用户兴趣变化较快的场景如新闻推荐、社交推荐。它更注重“兴趣小组”的发现。ItemCF适用于物品数量相对稳定、用户兴趣变化较慢的场景如电商、电影、音乐推荐。它更注重“物品关联”的发现结果往往更稳定可解释性更强“买了这个的用户也买了那个”。由于物品相似度矩阵相对稳定可以离线计算好因此ItemCF在工程上更常见。7. 工程化思考与常见问题排查当你把基础版本跑通后下一步就是思考如何让它变得更健壮、更高效。以下是一些进阶问题和解决思路7.1 性能瓶颈与优化问题用户/物品数量很大时计算全量相似度矩阵O(N^2)的复杂度无法接受。解决思路采样与聚类先对用户或物品进行聚类在簇内计算相似度。局部敏感哈希LSH用于快速近似地找到高相似度的邻居避免全量计算。矩阵分解MF如SVD将高维稀疏矩阵分解为低维稠密矩阵用隐向量内积表示相似度这是协同过滤的主流进化方向能显著提升性能和解决稀疏性问题。使用更高效的库用SciPy的pdist和squareform函数批量计算相似度矩阵比双重循环快得多。7.2 数据稀疏性与冷启动问题新用户没有行为或新物品没有被行为无法获得有效推荐。解决思路混合推荐对于新用户采用“热门推荐”、“基于地域/身份的粗粒度推荐”或者引导用户进行兴趣选择标签。对于新物品采用“基于内容的推荐”利用物品的元数据类别、标签、描述文本计算相似度推荐给喜欢过类似物品的用户。将多种策略结果加权融合例如最终得分 α * CF得分 β * 内容得分 γ * 热门度得分。7.3 相似度计算的陷阱问题1热门物品的干扰。两个用户都看过《肖申克的救赎》这种超级热门电影并不能说明他们兴趣相似。解决在计算相似度时对热门物品进行惩罚例如使用TF-IDF思想或Jaccard相似度的改进版本。问题2分数膨胀。有的用户习惯打高分4分起评有的则很苛刻3分就算好。解决这就是我们使用皮尔逊相关系数而不是余弦相似度的主要原因它通过减去均值来消除用户偏置。7.4 线上服务与实时性问题用户行为发生后如何快速更新推荐结果解决思路离线层每天或每小时全量更新用户/物品相似度矩阵和模型。近线层使用流处理框架如Flink实时接收用户行为事件更新用户的最新兴趣向量结合离线模型进行快速重排。在线层服务接收请求时从缓存中读取用户和物品的特征向量进行简单的实时计算如向量内积返回结果。整个架构通常是“离线训练 在线服务”的模式。亲手实现这个项目后你收获的不仅仅是一段可以运行的Python代码。你获得的是对推荐系统核心逻辑的透彻理解是从数据到算法、从理论到工程的全链路认知。下一次当你看到“协同过滤”、“用户画像”、“召回与排序”这些词时你的脑海里会浮现出具体的矩阵、相似度公式和预测流程而不再是模糊的概念。这才是“源码级”学习的意义所在。你可以尝试用MovieLens这样的小型公开数据集替换我们的模拟数据看看效果也可以挑战自己实现ItemCF甚至尝试最简单的矩阵分解SVD。每走一步你对这个领域的理解就会更深一层。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价