简介:本资源面向推荐系统入门与算法实践者,提供一套基于商品的协同过滤图书推荐系统完整实现,帮助理解如何从用户行为数据中挖掘商品关联并生成个性化推荐。包内共51个文件,以20个Python脚本为核心,覆盖数据读取、相似度计算、评分预测与推荐生成等模块,另含17个pyc编译文件、4个xml配置、3个html页面及css样式等,压缩包约797KB,目录结构清晰,便于按模块查阅。项目围绕商品-商品协同过滤展开,涉及余弦相似度与皮尔逊相关系数计算、稀疏数据处理及冷启动问题等关键环节,并附带用户-商品交互数据文件与推荐结果展示页面。已有566人学习下载,适合希望掌握协同过滤算法落地流程、对照代码理解推荐系统构建思路的开发者与学习者参考。
1. 商品协同过滤做图书推荐:为什么“买过又买”比“猜你喜欢”更靠谱
电商后台里最常见的推荐场景,不是首页那个花哨的“猜你喜欢”,而是详情页下方那行“买过这本书的人还买了”。这行字背后跑的大概率就是基于商品的协同过滤(Item-Based CF)。它不关心用户是谁、年龄几何、爱不爱看悬疑,只认一条死理:被同一批人反复一起买走的书,大概率还会被下一批人一起买走。图书推荐系统尤其吃这一套,因为图书的消费行为极度稀疏——一个用户一年可能就买三五本,但一本书的购买者却成千上万,从商品维度算相似度,比从用户维度算稳定得多。
这套方案适合谁?适合手里有订单流水、想快速搭一个能跑起来、解释得清、维护成本低的图书推荐模块的工程师。它不需要 GPU 集群,不需要用户画像标签体系,一台 4 核 8G 的机器就能把百万级订单的相似度矩阵算出来。但“能跑”和“跑得好”之间隔着参数、稀疏度和冷启动三道坎,下面按落地顺序拆开讲。
2. 商品相似度到底怎么算:从共现矩阵到余弦相似度
2.1 为什么选 Item-Based 而不是 User-Based
图书场景里用户行为稀疏得吓人。假设有 10 万用户、5 万本书,订单表可能只有 30 万行,用户-图书矩阵密度不到 0.006%。User-Based CF 要算“用户 A 和用户 B 的相似度”,两个人都只买过两三本书,交集大概率是零,相似度矩阵几乎全空。Item-Based CF 反过来,一本书的购买者动辄几百上千,两本书的共现次数足够支撑统计。
另一个现实原因是可解释性。推荐结果要展示“买过《三体》的人还买了《球状闪电》”,运营和用户都能看懂。User-Based 给出的“和你相似的人还买了”在图书场景里说服力弱得多,因为读书口味这件事,用户自己都不一定说得清。
常见做法是:用订单表构建“图书-用户”的 0/1 矩阵,转置后算图书两两之间的余弦相似度。注意是 0/1 而非评分,图书购买很少有显式评分,隐式反馈用二值化处理最稳。
2.2 共现矩阵的构建与稀疏处理
先看数据准备。假设订单明细表叫order_detail,字段是user_id、book_id、order_time。去重逻辑很关键:同一个用户多次购买同一本书,在相似度计算里只算一次,否则复购行为会扭曲相似度。
import pandas as pd import numpy as np from scipy.sparse import csr_matrix # 读取订单明细,只保留有效购买 df = pd.read_csv('order_detail.csv', usecols=['user_id', 'book_id', 'order_time']) df = df.dropna(subset=['user_id', 'book_id']) df = df.drop_duplicates(subset=['user_id', 'book_id']) # 同一用户同一本书只算一次 # 构建用户-图书二值矩阵 user_ids = df['user_id'].astype('category') book_ids = df['book_id'].astype('category') rows = user_ids.cat.codes.values cols = book_ids.cat.codes.values data = np.ones(len(df), dtype=np.float32) user_book = csr_matrix((data, (rows, cols)), shape=(len(user_ids.cat.categories), len(book_ids.cat.categories))) # 转置得到图书-用户矩阵,用于算图书相似度 book_user = user_book.T.tocsr() print('图书-用户矩阵形状:', book_user.shape) print('非零元素占比: %.6f%%' % (100 * book_user.nnz / (book_user.shape[0] * book_user.shape[1])))这段代码的核心是drop_duplicates和csr_matrix。去重保证隐式反馈的“买过即 1”,稀疏矩阵存储避免 5 万乘 10 万的稠密数组撑爆内存。book_user的每一行是一本书,每一列是一个用户,值为 1 表示该用户买过这本书。
参数上唯一需要留意的是dtype。用float32而不是默认的float64,相似度计算时内存直接减半,精度损失在推荐场景里可以忽略。
2.3 余弦相似度的计算与 Top-K 截断
有了book_user矩阵,图书相似度就是行与行之间的余弦值。直接算全量相似度矩阵是 5 万乘 5 万,稠密存储要 10GB 内存,必须用稀疏矩阵乘法配合 Top-K 截断。
from sklearn.preprocessing import normalize # L2 归一化,之后点积即余弦相似度 book_user_norm = normalize(book_user, axis=1, norm='l2') # 分块计算相似度,避免一次性生成超大矩阵 def compute_similarity_in_blocks(matrix, block_size=2000, top_k=50): n_books = matrix.shape[0] sim_rows, sim_cols, sim_vals = [], [], [] for start in range(0, n_books, block_size): end = min(start + block_size, n_books) # 当前块与全量矩阵点积 block_sim = matrix[start:end].dot(matrix.T).toarray() for i in range(block_sim.shape[0]): row = block_sim[i] row[start + i] = 0 # 自身相似度置零 # 取 Top-K top_idx = np.argpartition(row, -top_k)[-top_k:] top_idx = top_idx[row[top_idx] > 0] # 过滤零相似 sim_rows.extend([start + i] * len(top_idx)) sim_cols.extend(top_idx) sim_vals.extend(row[top_idx]) return csr_matrix((sim_vals, (sim_rows, sim_cols)), shape=(n_books, n_books)) sim_matrix = compute_similarity_in_blocks(book_user_norm, block_size=2000, top_k=50) print('相似度矩阵非零元素:', sim_matrix.nnz)分块计算是这里的关键工程手段。block_size=2000意味着每次只算 2000 本书与全量书的相似度,峰值内存可控。top_k=50表示每本书只保留最相似的 50 本,这既压缩了存储,也避免了长尾低相似度图书干扰推荐结果。
np.argpartition比全排序快一个量级,它只保证 Top-K 在正确位置,不保证内部有序。如果后续需要按相似度排序展示,再对这 50 个做一次小排序即可。
3. 从相似度到推荐列表:打分、过滤与排序的工程实现
3.1 用户已购图书的排除逻辑
算出相似度只是半成品,真正给用户推书时,必须把用户已经买过的书从候选集里剔除。否则推荐位出现“买过《活着》的人还买了《活着》”,体验直接翻车。
def recommend_for_user(user_id, user_book, sim_matrix, book_ids, top_n=10): # 取出该用户买过的所有书 user_row = user_book[user_id].toarray().ravel() bought = np.where(user_row > 0)[0] if len(bought) == 0: return [] # 冷启动用户,走兜底策略 # 累加相似度得分 scores = np.zeros(sim_matrix.shape[0]) for b in bought: scores += sim_matrix[b].toarray().ravel() # 已购图书得分置零 scores[bought] = 0 # 取 Top-N top_idx = np.argpartition(scores, -top_n)[-top_n:] top_idx = top_idx[np.argsort(scores[top_idx])[::-1]] return [(book_ids[i], scores[i]) for i in top_idx if scores[i] > 0]打分逻辑是“用户买过的每本书,把它相似度最高的 K 本书拿出来,按相似度加权累加”。买过《三体》和《球状闪电》,那么《三体》的相似书和《球状闪电》的相似书会叠加,共同邻居多的书得分更高。
book_ids是 category 编码到原始图书 ID 的映射表,推荐结果返回给前端时需要还原成真实 ID。这个映射在构建矩阵时就要存好,否则后面对不上号。
3.2 相似度归一化与热门惩罚
直接累加相似度有个隐患:热门书的相似度普遍偏高,因为买的人多,共现概率天然大。结果就是推荐列表被几本畅销书霸占,长尾好书永远出不来。常见做法是给相似度做热门惩罚。
| 策略 | 公式 | 适用场景 |
|---|---|---|
| 原始余弦 | sim(i,j) | 基线,快速验证 |
| 热门惩罚 | sim(i,j) / log(1 + pop(j)) | 长尾图书曝光不足 |
| 共现归一 | sim(i,j) * n_ij / (n_i + n_j) | 共现次数差异大 |
pop(j)是图书 j 的购买用户数。除以log(1+pop(j))后,热门书的相似度被压低,小众书之间的相似度相对提升。实际调参时,惩罚力度可以做成可配置项,A/B 测试看点击率变化。
# 热门惩罚示例 pop = np.asarray(book_user.sum(axis=1)).ravel() penalty = 1.0 / np.log1p(pop) # 对相似度矩阵按列乘惩罚系数 sim_matrix = sim_matrix.multiply(penalty[np.newaxis, :]).tocsr()注意multiply是逐元素乘,penalty[np.newaxis, :]把列向量广播到每一行。这一步放在相似度计算之后、推荐打分之前,顺序不能乱。
3.3 离线评估指标:Hit Rate 与 NDCG
推荐系统上线前必须离线验证。图书场景常用两个指标:Hit Rate 和 NDCG。做法是把每个用户最近一次购买行为留作测试集,用之前的行为训练,看推荐列表是否命中。
def hit_rate_at_k(test_items, rec_items, k=10): rec_set = set(rec_items[:k]) return 1.0 if len(rec_set & set(test_items)) > 0 else 0.0 def ndcg_at_k(test_items, rec_items, k=10): dcg = 0.0 for i, item in enumerate(rec_items[:k]): if item in test_items: dcg += 1.0 / np.log2(i + 2) idcg = sum(1.0 / np.log2(i + 2) for i in range(min(len(test_items), k))) return dcg / idcg if idcg > 0 else 0.0Hit Rate 衡量“推的里面有没有用户真买的”,NDCG 额外考虑位置,越靠前命中得分越高。图书推荐里 Hit Rate@10 能到 0.15 以上就算及格,0.25 以上算不错。如果低于 0.1,优先检查数据去重和相似度矩阵是否正常。
4. 避坑与排查:图书推荐系统上线前必须过的五道坎
4.1 现象:推荐结果全是同一本书的不同版本
原因:图书数据里同一本书有精装、平装、套装多个 ISBN,订单表里是不同 book_id,但用户眼里就是同一本。相似度计算时它们互相成为最相似的书,推荐列表被版本霸占。
解决:在数据预处理阶段做图书归一化,用书名+作者做聚合,或者维护一张 ISBN 到作品 ID 的映射表。归一化后重新构建矩阵,推荐结果按作品 ID 去重。
4.2 现象:新书永远不出现在推荐里
原因:新书没有购买记录,在共现矩阵里是零向量,与任何书的相似度都是零。Item-Based CF 天然偏向老书。
解决:设置兜底策略。当推荐列表不足或用户是新用户时,用“同分类热门书”或“编辑精选”填充。另外可以在相似度矩阵里给新书注入基于内容标签的伪相似度,比如同作者、同出版社的书给一个基础分。
4.3 现象:相似度矩阵计算到一半内存爆了
原因:block_sim = matrix[start:end].dot(matrix.T).toarray()里.toarray()把稀疏结果转成了稠密数组,如果 block_size 设得太大,单块就是 2000 乘 50000 的浮点数组,约 400MB,加上中间变量很容易撑爆。
解决:调小block_size到 500 或 1000,或者不转稠密,直接在稀疏矩阵上做 Top-K 提取。sklearn的pairwise_distances配合argpartition也能做,但要注意它默认输出稠密。
4.4 现象:离线指标很好,上线点击率却很低
原因:离线评估用的是“留最后一次购买”,但线上推荐位在详情页,用户刚看完当前书,推荐列表里如果全是和当前书高度相似的书,用户可能已经买过了。另外离线没有考虑展示位置和疲劳度。
解决:离线评估改成“留最后两次购买,用倒数第二次做推荐,看是否命中最后一次”,更贴近详情页场景。线上加实时去重,把用户当前浏览的书和最近已购的书从推荐池里剔除。
4.5 现象:相似度矩阵每天全量重算,耗时越来越长
原因:订单表只增不减,全量重算的代价随数据量线性增长。图书相似度其实变化很慢,每天全量算一次是浪费。
解决:改成增量更新。每天只重算有新购买记录的图书及其邻居的相似度,其余保持不变。维护一个“脏书”列表,新订单进来时把涉及的 book_id 标记为脏,定时任务只处理脏书。全量重算降到每周一次。
5. 让推荐结果更耐看:相似度融合与实时兜底的组合技巧
纯 Item-Based CF 跑通之后,下一步是让它不那么“机械”。我一般会做两件事:一是把相似度矩阵和内容标签做加权融合,二是给推荐接口加一层实时兜底。
先说融合。图书有分类、作者、出版社这些结构化标签,可以算一个内容相似度矩阵,然后和协同相似度按权重相加。权重怎么定?我的血泪经验是协同为主、内容为辅,比例大概 7:3。协同相似度反映真实购买行为,内容相似度解决新书冷启动和长尾覆盖。融合代码很简单:
# content_sim 是内容相似度矩阵,sim_matrix 是协同相似度 alpha = 0.7 final_sim = alpha * sim_matrix + (1 - alpha) * content_sim # 重新做 Top-K 截断 final_sim = top_k_truncate(final_sim, k=50)alpha这个参数不要拍脑袋,用离线 Hit Rate 扫一遍,0.6 到 0.8 之间通常有最优值。内容相似度可以用 TF-IDF 对书名+分类+作者做向量化,再算余弦,一天就能跑完。
再说实时兜底。推荐接口不能因为相似度矩阵加载失败就返回空列表。我的习惯是准备三级兜底:第一级走 Item-Based CF,第二级走同分类热门,第三级走全站热销。每一级都有超时控制,CF 查询超过 50ms 直接降级到第二级。这样即使矩阵服务抖动,推荐位也不会开天窗。
验证方法上,除了离线指标,上线后重点看两个数:推荐位的点击率和加购率。如果点击率正常但加购率低,说明推荐的书和当前书关联弱,需要调高协同相似度权重;如果点击率就低,检查推荐列表是不是被少数几本书霸占,把热门惩罚系数调大。
最后说一个我踩过的坑:相似度矩阵的存储格式。一开始用 CSV 存,500 万行非零元素,加载一次要 40 秒。后来换成npz稀疏格式,加载降到 2 秒以内。这个细节不起眼,但推荐接口的 P99 延迟就差在这里。希望帮到你。
本文还有配套的精品资源,点击获取