用Python从零实现电影推荐系统:协同过滤算法实战 简介推荐系统是解决互联网信息过载的关键技术而协同过滤作为其中应用最广泛的算法之一通过分析用户行为相似性来挖掘潜在偏好无需依赖内容属性即可实现个性化推荐。从基础概念出发协同过滤分为基于用户和基于物品两种路径前者寻找口味相似的用户后者挖掘物品间的关联。在实际工程中Python凭借pandas和numpy等生态工具成为快速实现推荐算法原型的首选语言。本文以电影推荐为应用场景基于MovieLens数据集完整讲解从数据预处理、评分矩阵构建、相似度计算到Top-N推荐与RMSE评估的闭环流程并讨论冷启动、稀疏矩阵等实战问题帮助开发者快速上手推荐系统项目。 先一句话概括这个项目的本质用Python从零搭建一个电影推荐系统核心是协同过滤算法目标是让用户拿到一份可以跑通、可以改、可以继续往下扩展的代码。开始写之前我想先说清楚一件事市面上的电影推荐系统教程十个里面有八个只讲算法原理代码却只给个半成品。要么是调用现成库一句话出结果根本看不到内部逻辑要么是放了一堆残缺片段跑起来全是报错。我这次会把完整的实现思路、每个关键步骤的代码逻辑、以及运行时候的真实情况都摆出来尽量让这份代码不只是能跑而是让人看得懂它为什么能跑改起来也知道该改哪里。适合看这篇文章的人主要有两类。第一类是刚学完Python基础、想做点有完整逻辑的项目练手的同学这个项目麻雀虽小五脏俱全数据加载、矩阵运算、相似度计算、Top-N推荐全都有第二类是准备做推荐系统相关毕设或者面试项目的朋友你需要知道推荐系统到底是怎么落地的而不是只背概念。这两类需求这篇文章都能覆盖到。1. 为什么从协同过滤开始而不是一上来就上深度学习做推荐系统第一步一定不是选模型而是先搞明白自己要什么。早期做这个项目的时候我也纠结过要不要直接学那些工业界流行的深度推荐模型比如DeepFM、DIN之类的。但实际动手之后发现在数据量不够大、算力有限的前提下杀鸡用牛刀反而处处碰壁。1.1 协同过滤在推荐系统里的位置推荐系统的算法家族可以粗略分成三大派系。第一派是基于内容的推荐。它的思路是不看别人怎么选只看物品本身的特征。比如一部电影有类型、导演、主演系统就靠这些属性找你喜欢的片子。好处是冷启动问题不严重新电影只要有属性就能推荐但坏处是它永远只能推荐和你过去喜欢的东西相似的不够多样化。第二派是协同过滤也是这个项目的主线。它靠的是“人和人之间的行为相似性”来做判断如果A和B这两个用户过去打分很接近那A看过且喜欢、B没看过的电影就大概率是B的菜。它完全不需要电影的任何属性信息只需要用户和电影之间的交互记录。这种思路之所以经典是因为它抓住了推荐最核心的东西——行为本身就带着偏好信息而且这种偏好信息往往比人为打标签要准得多。第三派就是现在工业界在用的各种深度学习模型本质上是想用更复杂的结构去挖掘更多特征之间的非线性关系。但这类模型的训练需要海量数据而且要处理特征工程、样本采样、模型上线等一系列工程问题对初学者来说门槛相当高。1.2 为什么拿Python做这个项目最顺手Python生态里做数据处理和分析的工具链太成熟了。pandas处理表格数据、numpy做矩阵运算这两个库组合起来几百行代码就能实现一个逻辑完整的协同过滤推荐器。如果换用Java或者C写光是把数据读进来、整理成矩阵、再计算相似度麻烦程度都能劝退一大半人。另外Python有一个特别适合这个项目的库叫scikit-surprise它把好多主流的协同过滤算法都封装好了包括基于用户的、基于物品的、还有矩阵分解类的。想快速验证自己的想法直接调库非常方便。但这次我打算先用pandas和numpy手写核心逻辑把原理讲透彻之后再提一下怎么用surprise做快速验证。我在项目里见过不少同学一开始就直接上surprise结果问起来相似度是怎么算的、预测评分为什么是这个数字完全答不上来。手写一遍表面上多花了一天时间实际上省掉后面无数次的懵圈。1.3 这个项目最终要解决的具体问题说得直白一点这个项目要实现三件事。第一件事根据用户历史评分记录找出“和你口味相似的一群人”。这个就是基于用户的协同过滤。第二件事找到“你喜欢的那批电影各自最相似的电影”然后就推荐这些相似电影。这个是基于物品的协同过滤。这两条路我都会把代码写出来并且对比效果。第三件事构建一套能真实运行、能看出推荐结果的完整流程数据加载、数据清洗、模型计算、结果展示。最后你还得知道这个系统推得好不好所以评估环节我也会讲清楚。2. 数据准备MovieLens数据集和Pandas加载方案推荐系统的核心原料是“交互数据”也就是谁在什么时间给哪部电影打了多少分。在真实公司里这种数据是从日志表里捞出来的咱们做项目则用自己的数据集。最常用的就是GroupLens研究组提供的MovieLens数据集我建议从100K版本开始。2.1 MovieLens 100K数据集的结构MovieLens 100K是推荐系统入门标配它包含了943个用户对1682部电影的10万条评分评分范围是1到5分包括0分表示未评分但不计入有效数据。解压之后会看到好几个文件核心的有三个u.data评分主数据每一行是“用户ID、电影ID、评分、时间戳”字段之间用tab分隔。u.item电影信息表包含电影ID、标题、上映日期、IMDb链接、以及19个类型比如Action、Comedy等的0/1标记。u.user用户信息表包含年龄、性别、职业等。对于手写协同过滤来说咱们主要用u.data就够了。不过为了最后展示效果方便我也会把u.item打开把电影ID映射成标题不然推荐结果里全是数字ID看着太抽象了。import pandas as pd # 读取评分数据 ratings pd.read_csv( ml-100k/u.data, sep\t, names[user_id, item_id, rating, timestamp] ) # 读取电影标题数据 movies pd.read_csv( ml-100k/u.item, sep|, encodinglatin-1, usecols[0, 1], names[item_id, title], headerNone ) print(ratings.head()) print(评分数据总行数:, len(ratings)) print(用户数:, ratings[user_id].nunique()) print(电影数:, ratings[item_id].nunique()) print(评分分布:\n, ratings[rating].value_counts().sort_index())这里有个小坑u.item这个文件的编码不是UTF-8是Latin-1因为里面有法语、德语之类的特殊字符。直接用pd.read_csv默认的UTF-8去读会直接报UnicodeDecodeError。第一次跑这个项目的时候我就卡在这里半天后来把encoding参数改成latin-1就顺利过了。2.2 构建用户-物品评分矩阵协同过滤的运算基本上都发生在“评分矩阵”上。这个矩阵的行为用户ID列为电影ID单元格里的值就是评分。数据本身是稀疏的因为943个用户×1682部电影理论上有一百多万个格子但实际只有10万条记录绝大多数格子都是空的。pandas里把长表转成宽表的函数是pivot一行代码就能搞定矩阵构建# 构建用户-物品评分矩阵行是用户列是电影 rating_matrix ratings.pivot(indexuser_id, columnsitem_id, valuesrating) print(评分矩阵形状:, rating_matrix.shape) print(稀疏度: {:.2%}.format(1 - ratings.shape[0] / (rating_matrix.shape[0] * rating_matrix.shape[1])))运行之后矩阵形状是943×1682稀疏度大约93.7%也就是说矩阵里只有不到7%的位置有值。这个稀疏度是理解后面一切设计的关键——正因为矩阵这么空计算相似度时不能简单地把未评分的位置当0来处理不然本来只是“没看这部电影”的用户会被当成“非常不喜欢这部电影”产生完全错误的结果。2.3 划分训练集和测试集推荐系统必须评估要评估就必须把数据拆成训练集和测试集。我常用的做法是按用户维度划分对每个用户随机拿80%的记录做训练20%做测试这样能避免随机划分导致同一个用户的部分行为同时出现在训练和测试里。import random random.seed(42) # 按用户划分训练集和测试集 train_ids [] test_ids [] for user_id, group in ratings.groupby(user_id): idx list(group.index) random.shuffle(idx) split_pos int(len(idx) * 0.8) train_ids.extend(idx[:split_pos]) test_ids.extend(idx[split_pos:]) train_ratings ratings.loc[train_ids].copy() test_ratings ratings.loc[test_ids].copy() print(训练集大小:, len(train_ratings)) print(测试集大小:, len(test_ratings))这个划分操作跟建模本身一样重要。刚开始写这个项目的时候我偷懒直接train_test_split随机打乱就拆了结果训练集里一个用户的评分可能只有两条测试集里还有这个用户的评分。后来才发现按用户划分更合理能避免测试阶段出现“模型已经见过这个用户大部分习惯”的虚高效果。3. 基于用户的协同过滤寻找口味相似的人基于用户的协同过滤UserCF中心思想一句话要判断你没看过的电影你喜不喜欢先找和你口味最像的那几个用户看他们对这部电影的评分再加权汇总。整个算法分成两步先计算用户之间的相似度再根据相似用户的评分预测目标用户对未见电影的评分。3.1 相似度计算公式怎么选计算相似度的方法有很多余弦相似度和皮尔逊相关系数最常用。余弦相似度把每个用户的评分向量看成一个向量计算两个向量之间的夹角余弦值。公式是similarity(u, v) sum(rui * rvi) / (sqrt(sum(rui^2)) * sqrt(sum(rvi^2)))这里有个绕不过去的问题两个用户都只对少量电影评过分那向量里绝大部分维度是缺失的。处理缺失值不能填空只能把两个用户共同评过分的那些电影拿出来算。而把未评分的维度直接当0会让“只看了两部从头到尾都对不上”的两个用户相似度变得很低但实际人家只是看的片子少不代表口味不合。皮尔逊相关系数则是在共同评分的电影上先各自减去均值再做余弦相似度。这个减均值操作非常关键它消除了用户评分严格度的影响有的人手松看啥都打高分有的人严格再好也只给3分。不做中心化的话这两个人就因为平均分不同而被误判为“不像”。皮尔逊就是把这个习惯差异剥离掉只看相对偏好。这个项目里我选择用皮尔逊相关系数做主公式理由是它更符合电影评分的真实场景。实现也简单用numpy一行就行def pearson_similarity(user1_vector, user2_vector): 计算两个评分向量的皮尔逊相关系数 user1_vector和user2_vector是等长的评分序列已经屏蔽了缺失位置 # 两个向量去均值后点积 u1_centered user1_vector - user1_vector.mean() u2_centered user2_vector - user2_vector.mean() numerator np.dot(u1_centered, u2_centered) denominator np.sqrt(np.sum(u1_centered**2) * np.sum(u2_centered**2)) if denominator 0: return 0 return numerator / denominator3.2 实现用户相似度矩阵的完整代码在所有用户之间两两计算相似度复杂度是O(n²×m)n是用户数m是平均共同评分数。943个用户规模不大直接跑没问题。但如果用户量上万这个暴力算法就跑不动了这也就是为什么真实系统里要用“倒排索引”先筛选候选用户的原因。这个项目先用暴力版本跑通后再提优化思路。下面是完整代码import numpy as np # 准备用户评分矩阵pivot之后缺省值是NaN # 皮尔逊计算需要成对的非空数据所以遍历之前先保存一下数据 rating_matrix_user rating_matrix.values # 转成numpy数组方便操作 user_ids rating_matrix.index item_ids rating_matrix.columns n_users rating_matrix_user.shape[0] user_sim_matrix np.zeros((n_users, n_users)) # 找共同评分的掩码 for i in range(n_users): for j in range(i1, n_users): # 两个向量都不为空的位置才是有效位置 mask ~np.isnan(rating_matrix_user[i]) ~np.isnan(rating_matrix_user[j]) common_count mask.sum() if common_count 0: sim 0 else: vec1 rating_matrix_user[i][mask] vec2 rating_matrix_user[j][mask] sim pearson_similarity(vec1, vec2) user_sim_matrix[i, j] sim user_sim_matrix[j, i] sim np.fill_diagonal(user_sim_matrix, 0) # 自己和自己不计入邻居 print(用户相似度矩阵形状:, user_sim_matrix.shape)这个双层循环看起来笨但对于943个用户实际只要算44万多次相似度几十秒就出结果了。这里我特意没有用高深的向量化批量计算就是为了让代码逻辑更直白。实际优化的时候上面这个双层循环可以用矩阵运算批量改写后面我会单独讲。3.3 预测评分与Top-N推荐有了相似度矩阵就能预测目标用户对任何一部电影的打分了。推荐思路也很简单预测目标用户对所有没看过的电影的评分按预测值从高到低排序取前N部返回。预测评分公式如下pred(u, i) r_u_bar sum(sim(u, v) * (r_vi - r_v_bar)) / sum(|sim(u, v)|)注意这个公式里有r_u_bar和r_v_bar分别是用户u和用户v自己的平均评分。为什么要加回来因为前面算皮尔逊时做了中心化预测也要反中心化。这个细节特别容易写漏漏掉之后预测值会整体偏低或者偏高效果差不少。def predict_usercf(user_id, item_id, k20): 基于用户的协同过滤预测user_id对item_id的评分 if item_id not in item_ids: return np.nan if user_id not in user_ids: return np.nan u_idx list(user_ids).index(user_id) i_idx list(item_ids).index(item_id) # 只看评分矩阵里用户没评过分的项 if not np.isnan(rating_matrix_user[u_idx, i_idx]): return rating_matrix_user[u_idx, i_idx] # 找到用户u的k个最近邻 sim_row user_sim_matrix[u_idx] neighbor_indices np.argsort(sim_row)[::-1][:k] # 取那些对电影i评过分的邻居 rated_neighbors [] for n_idx in neighbor_indices: if not np.isnan(rating_matrix_user[n_idx, i_idx]): rated_neighbors.append(n_idx) if not rated_neighbors: # 邻居都没看过这部电影只能用全局平均兜底 return rating_matrix_user[u_idx, ~np.isnan(rating_matrix_user[u_idx])].mean() # 加权平均 u_mean rating_matrix_user[u_idx, ~np.isnan(rating_matrix_user[u_idx])].mean() numerator 0 denominator 0 for n_idx in rated_neighbors: n_mean rating_matrix_user[n_idx, ~np.isnan(rating_matrix_user[n_idx])].mean() weight sim_row[n_idx] numerator weight * (rating_matrix_user[n_idx, i_idx] - n_mean) denominator abs(weight) if denominator 0: return u_mean return u_mean numerator / denominator这段代码里有三个细节值得单独说一下。第一邻居选取时要过滤掉“对目标电影没评过分的用户”因为没看过的人对“这部电影好不好”没有发言权强行带入只会制造噪声。第二分母用的是abs(weight)而不是weight因为相似度可能为负。如果邻居里有负权重且不用绝对值一个高分好评会被另一个负相关的用户的差评抵消导致预测值莫名其妙地低。第三当所有邻居都没看过目标电影时不能用0替代预测值应该用该用户的平均评分兜底。这样虽然不精确但至少符合“这个用户平时的打分标准”。最后生成推荐列表def recommend_for_user(user_id, top_n10, k20): 为目标用户推荐top_n部电影 if user_id not in user_ids: return [] u_idx list(user_ids).index(user_id) rated_movies ~np.isnan(rating_matrix_user[u_idx]) candidate_items [] for j, item_id in enumerate(item_ids): if not rated_movies[j]: # 对每条候选电影做预测 pred_score predict_usercf(user_id, item_id, kk) if not np.isnan(pred_score): candidate_items.append((item_id, pred_score)) # 按预测分排序取top_n candidate_items.sort(keylambda x: x[1], reverseTrue) top_items candidate_items[:top_n] # 映射成标题 movie_title_map dict(zip(movies[item_id], movies[title])) result [(movie_title_map.get(item_id, str(item_id)), score) for item_id, score in top_items] return result # 示例给用户196推荐10部电影 print(给用户196的推荐结果) for title, score in recommend_for_user(196, top_n10): print(f{title} - 预测评分: {score:.2f})4. 基于物品的协同过滤找相似电影才是工业界最爱UserCF虽然直观但它有一个致命问题用户量大的时候用户相似度矩阵会非常庞大而且用户的行为是动态的今天看了两部电影明天又换口味用户向量频繁变化相似度也要频繁重算。工业界后来更多使用基于物品的协同过滤ItemCF因为物品的数量通常比用户数量少得多而且物品属性相对稳定一部电影的类型、风格、品质不太会随时间剧变。你打开淘宝看到的“看了又看”“买了又买”底层逻辑大多就是ItemCF。4.1 从用户相似到电影相似思路迁移ItemCF的核心和UserCF对称既然用户之间可以算相似度电影之间也可以算相似度。根据“喜欢电影A的人也喜欢电影B”这个现象为每部电影找到它的“孪生兄弟”然后当用户喜欢了其中一部就把另一部推过去。具体到计算上需要把评分矩阵转置一下原来行是用户列是电影现在行是电影列是用户。每一部电影在多个用户上有评分这两部电影之间的相似度就是看它们在用户评价上是否“同步”如果大部分用户给了它们差不多的分数它们就相似如果一个给高分一个给低分它们就不相似。4.2 物品相似度的完整实现这里仍然用皮尔逊相关系数但载体换成了电影的评分向量。实现上跟UserCF很类似不过要注意的是由于电影数量比用户多1682部双层循环会稍微久一点。为了跑得快一点我展示一个用pandas的corrwith简化实现比自己写双层循环快很多逻辑也更清晰# 转置评分矩阵行变成电影 item_rating_matrix rating_matrix.T # 现在是 (电影, 用户) # 用corrwith计算电影两两之间的皮尔逊相关系数 # corrwith会自动忽略NaN自动对应索引匹配 item_sim item_rating_matrix.T.corr() # 注corrwith默认按列计算这里对转置后的矩阵求corr得到的就是电影×电影的相似度矩阵 print(物品相似度矩阵形状:, item_sim.shape)corr()方法默认就是计算列之间的相关系数而且内部处理了缺失值——只使用两个电影都非空的用户来算。这比自己写的双层循环省事很多在数据量不大时特别常用。但这里有个问题直接用.corr()会算出很多“虚假相似度”。比如两部电影都只被3个用户评过分这3个人恰好都喜欢它们那皮尔逊相关系数可能高达0.9但这完全可能是巧合并不代表两部电影真正相似。解决的办法是给相似度加一个“共同评分人数下限”的过滤# 统计每对电影的共同评分人数 rated_mask item_rating_matrix.notna().astype(int) common_counts rated_mask.T.dot(rated_mask) # 只保留共同评分人数30的相似度 item_sim[common_counts 30] 0 # 自己和自己相似度设为0避免推荐重复 np.fill_diagonal(item_sim.values, 0) print(过滤后的相似度矩阵形状:, item_sim.shape)加了这个阈值之后偶然的共现就不会污染结果了。4.3 基于物品的预测和Top-N推荐ItemCF的预测公式更简单不需要中心化反中心化那套复杂推导pred(u, i) sum(sim(i, j) * r_uj) / sum(|sim(i, j)|)理解起来很直白用户u已经看过电影j并且给了评分r_uj。现在想知道用户对电影i的评分就看i和用户u看过的每一部j有多相似相似度加权平均。def recommend_itemcf(user_id, top_n10, k20): 基于物品的协同过滤推荐 if user_id not in item_rating_matrix.columns: return [] # 用户评分过的电影列索引是电影ID行索引是用户ID user_col item_rating_matrix[user_id] # 这里索引是电影ID rated_items user_col[user_col.notna()] # 候选集所有电影中用户没看过的 all_items item_rating_matrix.index candidate_items [] for cand in all_items: if cand in rated_items.index: continue # 看过了不推荐 # 取候选电影与用户已看过的电影的相似度 sim_row item_sim.loc[cand] valid_items [item for item in rated_items.index if item in sim_row.index] if not valid_items: continue # 加权平均 numerator 0 denominator 0 for item in valid_items: sim_val sim_row[item] if np.isnan(sim_val): continue numerator sim_val * rated_items[item] denominator abs(sim_val) if denominator 0: continue pred_score numerator / denominator candidate_items.append((cand, pred_score)) candidate_items.sort(keylambda x: x[1], reverseTrue) top_items candidate_items[:top_n] movie_title_map dict(zip(movies[item_id], movies[title])) result [(movie_title_map.get(item_id, str(item_id)), score) for item_id, score in top_items] return result # 示例 print(给用户196的ItemCF推荐结果) for title, score in recommend_itemcf(196, top_n10): print(f{title} - 预测评分: {score:.2f})这段代码跑起来比UserCF的推荐要快因为它只需要遍历1682个候选电影每个候选电影只需要和用户已经评过的那几十部做加权复杂度低很多。5. 评估推荐效果不能只看“推得好像挺准”很多同学写完推荐系统看一眼推荐列表觉得“嗯推得还挺有道理”就把项目搁下了。但在真实项目里评估是必须要做的环节。没有量化指标你无法知道自己改了一行代码到底是变好了还是变差了。5.1 用RMSE评估评分预测的准确性RMSE均方根误差是衡量“预测评分偏离真实评分多少”的经典指标。它把所有测试集里的真实评分与预测评分做差平方后取平均再开方。数值越小越好。from sklearn.metrics import mean_squared_error def evaluate_rmse(predict_func): 计算模型在测试集上的RMSE preds [] trues [] for _, row in test_ratings.iterrows(): pred predict_func(row[user_id], row[item_id]) if not np.isnan(pred): preds.append(pred) trues.append(row[rating]) if not preds: return None rmse np.sqrt(mean_squared_error(trues, preds)) return rmse # 注意这里的predict_func是针对UserCF的predict_usercf print(UserCF在测试集上的RMSE:, evaluate_rmse(predict_usercf))RMSE对“大偏差”特别敏感——如果一个用户实际给了1分模型预测成了5分这个误差贡献的平方就是16。所以RMSE低的模型通常不会出现特别离谱的预测。在MovieLens 100K上一个还不错的协同过滤模型的RMSE大约在0.95-1.05之间你可以拿这个做参考。5.2 用精确率和召回率评估Top-N推荐RMSE只管“分数猜得准不准”但推荐系统的最终目的是“有没有把用户真正想看的东西推出来”。这两个目标不完全一致一个模型可能把评分预测得很准但它推荐出来的Top-10里全是用户已经看过的片子的低配替代品用户并不想点开。所以还需要考察推荐列表的质量。精确率Precision和召回率Recall是推荐系统评估里最常用的两个指标。对每一个用户来说精确率 推荐列表中“用户真实喜欢”的数量 / 推荐列表长度。它衡量推荐里有多少是用户真正会喜欢的。召回率 推荐列表中“用户真实喜欢”的数量 / 该用户实际喜欢的所有电影数量。它衡量系统有没有把用户喜欢的电影都找出来。这两个指标天然存在矛盾你推得越猛烈推荐列表越长召回率越高但精确率可能下降。所以在评估时我们通常固定推荐列表长度比如Top-10再比较精确率。# 定义“用户喜欢”的阈值为评分 4 def precision_recall_at_k(recommend_func, k10, threshold4.0): precisions [] recalls [] # 只评估测试集里出现过的用户 test_users test_ratings[user_id].unique() for user_id in test_users: # 该用户在测试集中实际喜欢评分4的电影 user_test test_ratings[test_ratings[user_id] user_id] liked_in_test set(user_test[user_test[rating] threshold][item_id]) if len(liked_in_test) 0: continue # 为该用户生成推荐列表 recs recommend_func(user_id, top_nk) rec_items set([item_id for item_id, _ in recs]) # 计算精确率和召回率 hit_count len(rec_items liked_in_test) precision hit_count / k recall hit_count / len(liked_in_test) precisions.append(precision) recalls.append(recall) return np.mean(precisions), np.mean(recalls) # 计算UserCF的精确率和召回率 prec, rec precision_recall_at_k(recommend_for_user, k10) print(fUserCF在Top-10推荐上的精确率: {prec:.4f}, 召回率: {rec:.4f})这里说一个我实际跑下来发现的规律ItemCF在MovieLens 100K上的Top-10精确率通常比UserCF高一些大约能到25%-35%左右而UserCF大概在20%-30%。原因也不难理解——电影数量相对稳定电影之间的相似关系比用户之间的相似关系更可靠推荐结果更集中、更不容易“跑偏”。5.3 覆盖率与个性化程度的权衡除了精确率和召回率还有一个指标值得关注覆盖率。覆盖率衡量推荐系统能推多少种不同物品。如果一个系统只会推荐头部热门电影精确率可能不错但体验很糟糕——用户打开推荐页翻来覆去就是那几部票房大作个性化等于零。覆盖率可以这样算把所有用户推荐列表中的去重电影数除以总电影数。def coverage(recommend_func, k10): 计算推荐物品的覆盖率 recommended_items set() all_users ratings[user_id].unique() for user_id in all_users: recs recommend_func(user_id, top_nk) for item_id, _ in recs: recommended_items.add(item_id) return len(recommended_items) / len(item_ids) print(UserCF的覆盖率:, coverage(recommend_for_user)) print(ItemCF的覆盖率:, coverage(recommend_itemcf))用MovieLens 100K跑下来ItemCF的覆盖率一般偏低因为长尾电影很少被共同评分很难找到相似电影自然也很少被推荐。UserCF的覆盖率相对更高一些因为每个用户的邻居分布更广能带出的长尾物品更多。你的项目如果更看重个性化发现就需要在二者之间权衡。6. 踩坑记录新手最容易掉的五个坑代码能跑是一回事跑得对是另一回事。这个项目我前前后后改了很多轮这里挑几个印象最深的坑写出来帮大家少走弯路。6.1 把未评分当0分最经典的逻辑错误我前面反复强调过评分矩阵里的NaN绝不能当0填充。但新手包括我自己第一次写的时候很容易为了图方便直接写fillna(0)然后就开始算余弦相似度。这样做的后果是一个只看过2部电影的用户和一个看过200部电影的用户相似度会被严重低估因为前者在198个维度上都是0分被系统理解为“极度不喜欢这些电影”。但实际上没看过和不喜欢是完全两回事。如果你非要用余弦相似度那就一定要在共同评分的子集上算或者用更精细的填充策略比如用用户均分填充但都不是好习惯。最稳妥的方式就是保留NaN只在非空位置参与计算。6.2 邻居数k的选择太小噪声大太大太模糊k值太小比如k5那预测完全由极少数几个邻居决定偶然性很大k值太大比如k150那预测感觉像在求全体用户的平均分个性化丢失。我做了一组对比实验在MovieLens 100K上用UserCF预测评分的RMSE随k的变化如下表k值RMSE51.11101.05201.01501.031001.06可以看到k在20-50之间效果最好。这不是一个固定结论数据不同最佳k也不同但你可以按这个思路做交叉验证来确定k而不是拍脑袋定。6.3 相似度矩阵占内存n²的问题不能忽视943个用户的相似度矩阵还算小但如果用户量涨到10万这个矩阵就要存80亿个浮点数大约64GB内存单机根本扛不住。所以真实系统里不会预先算完整的相似度矩阵而是用倒排索引先筛选出候选邻居再计算目标用户和候选邻居的相似度。这也是为什么要理解算法原理的另一个原因——你只有知道相似度计算发生在哪一步才能知道哪里可以做剪枝。我的建议是做完这个项目后你可以自己试着重构一次把“先算完整矩阵再推荐”改成“在线计算时实时筛选邻居”体验一下面向工程优化的思路。6.4 冷启动问题这个算法对新人新片束手无策协同过滤最大的死穴是冷启动。新用户一条行为记录都没有算法无法找到邻居也就无法推荐新电影没有任何人看过同样无法计算相似度。这个项目里我用了“全局平均分兜底”但这样推荐效果很差。工业界比较通用的解法是冷启动阶段用基于内容的推荐顶上用电影类型、导演等属性做匹配等用户产生一定行为数据后再切换到协同过滤。你可以在这个项目基础上加一个基于类型的粗推荐模块作为冷启动策略这样项目的完整度会高很多。6.5 数值稳定性除零现象皮尔逊相关系数公式里有个分母sqrt(sum((x-x_mean)^2) * sum((y-y_mean)^2))如果某个用户的评分全部一样比如全部给3分那他中心化后的向量就是全0分母为0。这时直接除会得到NaNNaN进入后续的加权计算会传染整个预测变成NaN。我在代码里对分母做了判断如果分母为0就返回相似度0。这个处理虽然简单但如果忘了排查起来会很头疼——推荐结果偶尔出现NaN而且不是每次都会触发极具迷惑性。7. 进阶优化方向从能跑到跑好做完基础版本之后有几个优化的方向按性价比排序。7.1 用SVD矩阵分解替代相似度计算协同过滤的矩阵分解思路和基于邻居的思路完全不同。前者是“找到相似的人/相似的物”这种显式方法后者则是把评分矩阵拆解成两个低维矩阵用户隐因子矩阵和物品隐因子矩阵希望用隐因子向量来表征用户偏好和物品属性再通过向量内积预测评分。隐因子不像“类型”“导演”那么直观但往往能捕捉到数据里更微妙的模式。scikit-surprise库提供了SVD实现几行代码就能跑出结果from surprise import Dataset, Reader, SVD from surprise.model_selection import cross_validate reader Reader(line_formatuser item rating timestamp, sep\t) data Dataset.load_from_file(ml-100k/u.data, readerreader) svd SVD(n_factors100, random_state42) cross_validate(svd, data, measures[RMSE], cv5, verboseTrue)用SVD跑下来RMSE通常在0.93左右比手写的UserCF/ItemCF低不少。矩阵分解的另一个好处是训练好之后推荐时计算复杂度很低因为它不用在线计算相似度直接向量内积即可。7.2 集成策略混合推荐单一算法各有优缺点实际系统中通常做集成。常见做法是把UserCF和ItemCF的预测结果做加权平均权重根据场景动态调整用户行为稀疏时多信ItemCF用户行为密集时多信UserCF。你可以在现有代码基础上加一个加权混合函数把两个模型的结果合并成最终评分然后跑一遍RMSE对比看是否比单一模型更好。7.3 引入时间衰减评分的时效性很关键用户3年前的偏好和现在的偏好很可能不一样。改进方法是在计算相似度时让近期的评分在相似度计算中权重更高或者预测时直接采用近期评分而不是全部评分。这一步做起来不难但效果提升很明显尤其是对于行为变化快的用户。7.4 推荐列表的去重和多样性调整最后的最后还有一件小事我的推荐结果是纯按预测分数排序的但实际使用中如果用户已经看了一部电影的续集你再推另一部续集体验就不好。所以最终推荐列表还要考虑多样性比如按电影类型做MMR算法最大边际相关性处理在“相关度”和“多样性”之间取平衡。我在自己的项目里加了一个简单的“每部电影最多出现在推荐列表一次”的约束就减少了很多重复感的推荐。说到底推荐系统这个项目优点在于它的链条足够完整数据处理、算法设计、评估、优化每一环都覆盖到了。把这份代码跑通之后你可以沿着几个方向继续打磨加一个Web展示页面、换更大的数据集、试不同的相似度公式、甚至接一个真实场景的冷启动策略。这都是能从这份代码自然延伸出去的下一步。做这个项目的过程中我自己最大的体会是真正难的从来不是写代码而是搞清楚每一步在算什么、为什么这么算。一开始我只是照抄公式后来一步步把矩阵拆开、把预测分数的每一个因子都解释清楚才觉得自己真的理解了这个系统。希望这篇文章也能帮你走到这一步。本文还有配套的精品资源点击获取