☰
商品协同过滤图书推荐实战:从相似度计算到线上增量更新
2026/10/25 12:31:02 网站建设 项目流程

简介:本资源面向推荐系统入门与进阶学习者,提供一套基于商品的协同过滤算法实现图书推荐系统的完整Python项目源码,适合希望理解推荐算法原理并动手实践的开发者。项目围绕商品-商品协同过滤展开,涵盖数据预处理、余弦相似度与皮尔逊相关系数计算、评分预测及推荐生成等核心模块,并涉及稀疏数据处理与冷启动等优化思路。压缩包共51个文件,以20个py脚本和17个pyc编译文件为主,辅以xml配置、html模板与css样式,整体约797KB,目录结构清晰,包含数据集、模型代码与结果展示等模块。目前已有566人学习下载,读者可借助源码快速搭建推荐流程,理解用户行为数据到个性化推荐的完整链路,并在此基础上进行二次开发与算法对比实验。

1. 商品协同过滤做图书推荐:为什么你跑完 demo 却推不准

图书推荐系统里,基于商品的协同过滤(Item-Based CF)是最常被低估的一类方案。很多人第一次接触协同过滤算法,都是从用户协同过滤起步,觉得“找相似用户”更符合直觉,结果一上真实图书数据就翻车:用户借阅记录稀疏得可怜,两两用户之间几乎没有交集,相似度矩阵全是零。而基于商品的协同过滤换了个思路——不找相似的人,找相似的书。一本书被哪些人借过、评分过,这个向量通常比单个用户的兴趣向量稳定得多,图书推荐系统里“同一批读者反复借阅同类书”的规律也远比“两个读者口味一致”更容易捕捉。

这篇文章面向的是手里已经有一份图书借阅或评分数据、想用协同过滤算法搭出一套能实际跑起来的推荐系统的工程师。我会把基于商品的协同过滤从相似度计算、评分预测、推荐生成到线上落地拆成可复现的步骤,重点讲清楚参数怎么设、稀疏矩阵怎么处理、冷启动怎么兜底,以及那些跑完 demo 就以为大功告成的人最容易踩的坑。读完你应该能拿自己的数据直接改一版跑通,并且知道推到生产前还要补哪些东西。

2. 商品相似度矩阵:从共现到余弦的选型与计算

2.1 为什么图书场景优先选 item-item 而不是 user-user

图书借阅数据的典型特征是“长尾极长、头部集中”。少数畅销书被大量用户借阅,绝大多数书只有个位数记录。用户协同过滤要算用户之间的相似度,两个普通读者共同借过的书可能只有一两本,相似度要么是零要么噪声极大。而商品协同过滤算的是书与书之间的相似度,一本书的借阅向量长度等于借过它的人数,头部书动辄几百上千,向量足够稠密,相似度才有统计意义。

另一个现实原因是稳定性。用户的兴趣会随时间漂移,今天借编程书明天借小说,用户向量变化快;而一本书的主题、类别基本固定,商品向量相对静态。这意味着商品相似度矩阵可以离线批量算好、缓存复用,线上只做一次查表和加权求和,响应时间能压到毫秒级。图书推荐系统里常见的“猜你喜欢”“看了又看”模块,底层大多就是这张 item-item 相似度矩阵。

选型上还要注意:item-item 适合“物品数远小于用户数”或“物品向量比用户向量稠密”的场景。图书库通常几万到几十万本,用户可能上百万,物品数反而更小,矩阵规模可控。如果你的书只有几千本但用户几十万,item-item 的优势会更明显。

2.2 相似度度量:余弦、调整余弦和杰卡德怎么选

相似度公式直接决定推荐质量,不能随手挑一个。图书数据分两种:显式评分(1-5 星)和隐式行为(借阅/收藏/点击)。两种数据适用的度量不一样。

显式评分推荐用调整余弦相似度(Adjusted Cosine)。普通余弦把用户评分尺度差异忽略了——有人习惯全打 5 分,有人最高只给 3 分,直接算余弦会把这些偏差带进相似度。调整余弦先减去每个用户的平均分再算,能消掉个人打分习惯。

隐式行为(只有借没借、没有分数)用杰卡德相似度更合适,它只看共同借阅的书占两本书并集的比例,不关心次数。如果借阅次数有意义(借了 5 次比借 1 次更能说明偏好),可以先用 TF-IDF 加权再算余弦。

import numpy as np from sklearn.metrics.pairwise import cosine_similarity # 评分矩阵 R: 行=用户, 列=图书, 缺失值为 0 # 调整余弦:先减去每个用户的平均分(只对该用户有评分的项) def adjusted_cosine(R): # 每行非零元素的均值 user_mean = np.true_divide(R.sum(axis=1), (R != 0).sum(axis=1)) user_mean = np.nan_to_num(user_mean) # 防止除零 R_adj = R.copy() mask = R != 0 # 只对已评分位置减去用户均值 R_adj[mask] = R[mask] - np.repeat(user_mean, R.shape[1]).reshape(R.shape)[mask] # 计算列与列(图书与图书)之间的余弦相似度 sim = cosine_similarity(R_adj.T) return sim # 杰卡德相似度(隐式 0/1 矩阵) def jaccard_similarity(R_binary): intersection = R_binary.T @ R_binary # 共同借阅数 union = R_binary.sum(axis=0)[:, None] + R_binary.sum(axis=0)[None, :] - intersection return intersection / np.where(union == 0, 1, union)

adjusted_cosine里user_mean用np.true_divide算的是每个用户已评分图书的平均分,np.nan_to_num处理没有任何评分的空用户。R_adj[mask]只修改有评分的位置,缺失值保持 0,这样余弦计算时缺失项不参与。cosine_similarity(R_adj.T)把矩阵转置,让列(图书)变成行,算出来就是图书两两相似度。

jaccard_similarity里intersection是共同借阅计数,union用广播机制算并集,分母加np.where防止两本都没人借时除零。这两个函数返回的都是对称方阵,对角线是书和自己相似度为 1,实际使用时要置零避免推荐自己。

2.3 相似度矩阵的稀疏化与 Top-K 截断

算出来的相似度矩阵是稠密的,几十万本书就是几十万乘几十万的方阵,内存直接爆掉。而且大部分相似度是接近零的噪声,留着只会拖慢线上查询、拉低推荐精度。标准做法是每本书只保留最相似的 K 个邻居,其余置零。

K 的取值是血泪经验:太小(比如 10)会导致长尾书找不到足够邻居,推荐结果单一;太大(比如 500)会引入弱相关噪声,还会让线上加权求和变慢。图书场景我一般从 20 到 50 之间调,用离线指标(召回率@N、NDCG)扫一遍选最优。头部书可以适当放大 K,长尾书缩小 K。

def topk_similarity(sim, k=30): sim = sim.copy() np.fill_diagonal(sim, 0) # 去掉自身 # 每行只保留前 k 大的值 for i in range(sim.shape[0]): row = sim[i] if np.count_nonzero(row) > k: threshold = np.partition(row, -k)[-k] row[row < threshold] = 0 return sim

np.fill_diagonal先把对角线置零,避免“推荐和自己最像的书”这种无意义结果。np.partition(row, -k)[-k]是找第 k 大的值作为阈值,比全排序快。逐行处理在几十万规模下会慢,生产环境通常用稀疏矩阵(scipy.sparse)存 Top-K 结果,只存非零的 (i, j, score) 三元组。

注意:Top-K 截断后一定要重新归一化或至少确认相似度范围,否则后续加权求和时不同书的邻居数量差异会导致评分尺度不一致。

3. 评分预测与推荐生成:把相似度变成可解释的推荐列表

3.1 基于相似度加权的评分预测公式

有了 item-item 相似度矩阵,预测用户 u 对图书 i 的评分,思路是:找出用户 u 已经借过/评过的书里,和书 i 最相似的若干本,用它们的评分加权平均。公式是:

pred(u, i) = Σ(sim(i, j) * r(u, j)) / Σ|sim(i, j)|,其中 j 遍历用户 u 评过且与 i 有相似度的书。

这个公式比用户协同过滤的预测更稳,因为分母是相似度之和,天然做了归一化。如果用户对 i 的邻居一本书都没评过,预测失败,需要走兜底策略(后面讲冷启动)。

def predict_rating(user_ratings, item_sim, target_item, k=20): # user_ratings: dict {item_id: rating} 该用户已评分的书 # item_sim: 二维数组,item_sim[i][j] 是书 i 和 j 的相似度 sims = [] for item_id, rating in user_ratings.items(): if item_id == target_item: continue s = item_sim[target_item][item_id] if s > 0: sims.append((s, rating)) if not sims: return None # 无邻居,走兜底 sims.sort(reverse=True) sims = sims[:k] # 只取最相似的 k 个 numerator = sum(s * r for s, r in sims) denominator = sum(abs(s) for s, _ in sims) return numerator / denominator if denominator != 0 else None

user_ratings是该用户的历史,item_sim[target_item][item_id]查相似度表。sims.sort(reverse=True)后截断 k 个,和离线 Top-K 呼应。numerator是相似度乘评分的累加,denominator是相似度绝对值和。返回None表示没有可用邻居,调用方必须处理,不能直接当 0 分。

3.2 生成 Top-N 推荐列表的完整流程

预测单个评分只是中间步骤,实际要的是给每个用户生成一个排好序的推荐列表。流程是:对用户没借过的每本书算预测分,排序取前 N,再过滤掉已借阅的。

def recommend_for_user(user_ratings, item_sim, all_items, top_n=10): scores = [] for item in all_items: if item in user_ratings: continue # 已借过不推 pred = predict_rating(user_ratings, item_sim, item) if pred is not None: scores.append((item, pred)) scores.sort(key=lambda x: x[1], reverse=True) return scores[:top_n]

all_items是全量图书 ID 列表。if item in user_ratings: continue做已读过滤,这一步不能省,否则推荐里全是用户已经借过的书。pred is not None过滤掉无邻居的书。scores.sort按预测分降序,取前top_n。

这个朴素实现有个性能问题:对每个用户遍历全量图书,用户多了会非常慢。生产环境的标准优化是倒排:先根据用户历史找到相似书集合,只对这些候选算分,候选规模能从几十万降到几百。另一个优化是离线批量算好所有用户的推荐结果存 Redis,线上直接查。

3.3 推荐结果的可解释性与业务过滤

图书推荐系统有个特殊要求:推荐结果要能解释。读者看到“推荐这本书”会想知道为什么。Item-based CF 天然支持解释——推荐书 i 是因为你借过的书 j 和它相似,直接把 j 作为推荐理由展示即可。这也是它比矩阵分解类方法更适合图书场景的原因之一,后者是黑匣子,解释不了。

业务过滤同样重要。图书场景常见的过滤规则包括:过滤已借阅、过滤下架/停借图书、过滤同 ISBN 的重复版本、控制同一作者或同一分类的推荐占比避免列表同质化。这些规则要放在排序之后、返回之前,顺序错了会浪费算力或漏过滤。

def apply_business_rules(recs, borrowed, unavailable, max_per_category=3): filtered = [] category_count = {} for item, score in recs: if item in borrowed or item in unavailable: continue cat = get_category(item) # 查图书分类 if category_count.get(cat, 0) >= max_per_category: continue category_count[cat] = category_count.get(cat, 0) + 1 filtered.append((item, score)) return filtered

borrowed和unavailable是集合,查询 O(1)。max_per_category限制同一分类最多出现几本,防止推荐列表全是同一类书。这个参数图书场景一般设 2 到 4,设 1 太严会导致候选不足,设太大等于没限制。

4. 避坑与排查:图书协同过滤最常见的 5 个翻车现场

4.1 现象:推荐结果全是畅销书,长尾书永远推不出来

原因:相似度矩阵被头部书主导。畅销书借阅人数多,和谁算相似度都不低,导致每个用户的推荐列表都被这几本占据。长尾书因为邻居少,预测分算不出来或被淹没。

解决:两个方向。一是对相似度做惩罚,热门书的相似度乘以一个衰减因子(比如 1/log(1+借阅人数)),压制头部。二是在排序后做重排,强制保证一定比例的长尾书。我一般两个一起用,衰减因子负责源头,重排负责兜底。

4.2 现象:新书上线后永远不被推荐

原因:新书没有任何借阅记录,相似度矩阵里它的行全是零,预测阶段找不到邻居,直接被过滤。这是典型的物品冷启动。

解决:新书走单独的召回通道,用内容特征(分类、作者、标签)做相似度,和协同过滤结果融合。等新书积累到一定借阅量(比如 50 次)再切回协同过滤。不要指望协同过滤自己解决冷启动,它解决不了。

4.3 现象:离线指标很好,线上点击率却很低

原因:离线评估用的是留一法或时间切分,但训练集里包含了未来信息,或者评估指标(RMSE)和线上目标(点击/借阅)不一致。评分预测准不代表推荐排序对。

解决:离线评估改用排序指标(Recall@N、NDCG@N),并且严格按时间切分,用 T 时刻之前的数据训练,预测 T 之后的交互。线上做 A/B 测试,别只看离线数字就全量。

4.4 现象:相似度矩阵计算时内存溢出

原因:直接算全量稠密矩阵,几十万本书的方阵需要几百 GB 内存。或者用了cosine_similarity但没做稀疏化。

解决:用 scipy.sparse 存评分矩阵,相似度计算分块进行,每块算完立即做 Top-K 截断再存稀疏格式。或者用 ALS、Faiss 这类支持大规模近似最近邻的库替代精确计算。图书场景几十万规模,分块加 Top-K 截断足够。

4.5 现象:推荐列表里出现同一本书的不同版本

原因:图书数据里同一本书常有多个 ISBN(精装、平装、不同出版社),协同过滤把它们当不同的书,相似度很高,推荐时一起出现,用户体验很差。

解决:在数据预处理阶段做版本归并,用作品 ID 或标题+作者做去重键,把多版本映射到同一个逻辑图书。这一步必须在算相似度之前做,事后过滤成本高且容易漏。

5. 从离线到线上:增量更新与效果验证的实操技巧

协同过滤算法跑通离线只是起点,真正难的是让它持续跟上新数据。图书借阅是持续发生的,相似度矩阵如果一周才全量重算一次,新书的邻居关系会严重滞后。我一般用“全量+增量”双轨:每天凌晨全量重算一次保证一致性,白天每小时做一次增量更新,只更新有新借阅记录涉及的那部分图书的相似度。

增量更新的关键是只重算受影响的行。一本书有了新借阅,它的向量变了,和它有共同读者的书相似度都可能变。实操上维护一个倒排索引:书 → 借过它的用户列表,新借阅发生时,通过这个索引快速找到需要重算的候选书集合,只更新这些行。

def incremental_update(new_interaction, user_items, item_users, item_sim, k=30): user, item = new_interaction # 找到和这本书有共同读者的候选书 candidates = set() for other_item in user_items[user]: candidates.update(item_users.get(other_item, [])) candidates.discard(item) # 只重算候选书的相似度行 for cand in candidates: # 重新计算 cand 和 item 的相似度(简化示意) new_sim = compute_pair_similarity(cand, item) item_sim[cand][item] = new_sim item_sim[item][cand] = new_sim # 对受影响的行重新做 Top-K 截断 for cand in candidates | {item}: item_sim[cand] = topk_row(item_sim[cand], k) return item_sim

user_items是用户到图书的倒排,item_users是图书到用户的倒排。candidates收集所有和用户历史书有共同读者的书,这些是可能受影响的。compute_pair_similarity是单对相似度计算,比全量快得多。最后对受影响的行重新 Top-K 截断,保证稀疏性。

效果验证不能只看离线。我习惯在离线指标之外加两个线上监控:推荐覆盖率(有多少本书被推荐过)和推荐新颖性(推荐列表里用户没接触过的分类占比)。覆盖率持续下降说明推荐在收敛到头部,新颖性下降说明推荐同质化。这两个指标比单纯的点击率更早暴露问题。

最后一个具体技巧:相似度矩阵的版本管理。每次重算生成一个带时间戳的版本,线上查询时记录用了哪个版本,出问题时能快速回滚和定位。我吃过亏——有一次增量更新逻辑写错,把一批书的相似度算成了 NaN,线上推荐直接空白,因为没有版本记录,排查花了半天。从那以后我坚持相似度矩阵必须带版本号,更新前先在影子环境验证,确认指标正常再切流量。这个习惯帮我省了不止一次后悔药。

希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询