基于商品的协同过滤图书推荐系统:原理、Python实现与避坑指南
简介这是一份基于Python的图书推荐系统完整工程依托豆瓣图书与评分信息采用基于商品的协同过滤item-based CF算法实现个性化推荐适用于推荐算法学习、毕业设计或课程项目。项目使用Django框架搭建涵盖用户登录、图书展示、推荐结果等模块代码结构清晰便于二次开发。资源共52个文件压缩包约797KB以20个Python源码及17个pyc字节码文件为主体包含Django的配置、路由、视图、模型等核心逻辑另有4个XML配置、3个HTML页面、2个CSS样式以及README、数据文件等既有完整Web前后端也提供了算法实现与说明文档。目前已有78人浏览学习。所有代码均测试运行成功上传前经过验证答辩评审平均分达96分下载后可按README快速上手若遇到运行问题可私信咨询远程教学。适合计算机相关专业学生、初学者及开发者作为项目实践和算法入门的参考。1. 把基于商品的协同过滤做到图书推荐系统这个项目值不值得做把基于商品的协同过滤Item-Based Collaborative Filtering简称 ItemCF做成一个图书推荐系统是 Python 入门推荐系统时性价比最高的练手项目。它的核心逻辑一句话就能说明白用户喜欢《三体》就把《球状闪电》也推给他因为大量读者同时给这两本书打过分。标题里附带源代码、数据集和文档说明意味着你不用从零造轮子重点应该放在理解算法、改参数、处理数据这三件事上。适合刚学完 Python 基础、想接触推荐系统但不想直接啃深度学习的人也适合要做毕业设计或简历项目、需要一份能讲清楚原理和工程细节的作品的人。这篇文章按“原理 → 数据 → 代码 → 踩坑 → 优化”的顺序把完整的落地路径拆开讲。2. 基于商品的协同过滤图书推荐为什么弃用用户协同2.1 核心假设与预测公式用“也喜欢”代替“猜你喜欢”基于商品的协同过滤有一个明确的前提假设如果两本书经常被同一批用户评分那么它们对某个用户的吸引力是相近的。这个假设不关心用户是谁只关心书和书之间的关系因此算法的计算重心全部落在“物品相似度”上。预测用户对未读图书的打分时常见做法是把他对已读书籍的评分按相似度加权汇总对于用户 u 和图书 i预测分 用户对所有已评分图书 j 的评分 r(u,j) 乘上 i 与 j 的相似度 sim(i,j)再除以相似度绝对值之和做归一化。这样设计的好处是评分越高的书、与目标书越相似的书对最终预测的影响越大同时避免只看一本相似书就下结论。代码实现时这个公式会变成一个 for 循环遍历用户已读的每一本书去查这本书的相似邻居表把邻居的评分累加到一个候选字典里。后面第 4 章会给出完整代码这里先记住两个关键变量一个是相似度阈值哪些邻居才有资格参与预测另一个是邻居数量 TopK最多取几个邻居。这两个值直接决定推荐结果的口味。2.2 为什么图书场景下 ItemCF 比 UserCF 更稳基于用户的协同过滤UserCF是另一条经典路线先找和你口味最像的一群人再把那群人读过而你没读过的书推给你。听起来合理但在图书场景里有两个现实问题。图书的评分天然稀疏。一个中型数据集里可能有上万本书但一个普通读者一年也就读几十本两两用户之间的共同评分书目往往少得可怜计算出的“相似用户”并不可靠。图书又是一种长尾商品头部畅销书被大量人评过分而长尾图书的用户交集非常小UserCF 在长尾上的表现尤其差。ItemCF 在工程上还有一个隐性优势物品相似度矩阵可以离线预先算好。用户之间的相似度需要实时计算或定期更新但书和书的关系相对稳定今天算完明天还用得上。推荐时只需要在线的部分对用户已读书籍做聚合查询延迟可控。对图书这种兴趣变化慢的内容产品来说ItemCF 的维护成本比 UserCF 低一个量级。所以图书推荐系统的常规起步方案是 ItemCF而不是 UserCF。UCF 更适合新闻、短视频这类用户兴趣实时变化的场景CF 的选型要先问一句“物品关系稳不稳定”而不是跟风选最新的模型。把这句话记在脑子里能帮你避开很多后面章节要讲的坑。3. 图书评分数据集怎么准备从原始 CSV 到稀疏矩阵3.1 数据集的最小字段与最少数据量标题里提到的数据集最常见的交付格式是三列或四列的 CSV 文件user_id、book_id、rating外加可选的 timestamp。不要设计超过五个字段的复杂表结构推荐系统的第一版只需要这三样元数据书名、作者、分类是后置需求。字段类型上有一个容易被忽略的细节book_id 既不要用自增整数也不要用书名本身用出版方提供的 ISBN 或内部唯一编号即可。自增整数会让新书加入时 ID 冲突书名做 ID 则会在重名书和版本差异上翻车。timestamp 字段建议保留它不只用于排序后面做时间维度评估、复现“用过去预测未来”的实验都靠它。最小数据量按经验看至少要有 2000 个用户、1 万本以上的书、5 万条以上评分且平均每个用户评分不少于 5 条、每本书评分人数不少于 2 人。低于这个量级ItemCF 的相似度计算会因为共同评分太少而失真。如果手头数据集达不到优先保证“每本书至少被 2 人评分”而不是盲目扩充用户数。数据清洗阶段必做三件事删除 user_id 和 book_id 为空的记录、把 rating 限定在 1 到 5 的区间内、按 (user_id, book_id) 去重。重复评分的场景很容易被忽略——同一个人对同一本书在前后台各评了一次不去重的话这本书会同时出现在“已读”和“待推荐”里推荐时会直接被过滤掉造成行为数据莫名丢失。3.2 用字典构建稀疏评分矩阵告别二维数组初学者最容易踩的坑是上来就用 numpy 建一个 user × book 的二维矩阵。假设有 5000 个用户、2 万本书那是一个 5000 行乘 2 万列的大方阵内存占用接近 800 MB而且里面 95% 以上是 0。设备稍差就直接卡死连算法都跑不到。正确的做法是只在必要时刻才构建矩阵平时用稀疏结构躲着。Python 里最顺手的是嵌套字典外层 key 是 book_id内层 key 是 user_idvalue 是评分。这个结构正好对应后面的相似度计算——算两本书的相似度时只需要把两个内层字典的 key 集合取交集找出共同评分用户即可。构建数据集的逻辑如下import csv from collections import defaultdict def load_ratings(path, sep,): 读取评分CSV返回 (user_id, book_id, rating) 三元组列表 ratings [] with open(path, r, encodingutf-8) as f: reader csv.reader(f, delimitersep) next(reader, None) # 跳过表头 for row in reader: if len(row) 3: continue try: uid int(row[0].strip()) bid int(row[1].strip()) rat float(row[2].strip()) ratings.append((uid, bid, rat)) except ValueError: # 脏数据直接跳过不做强校验 continue return ratings def build_item_user(ratings): 构建 商品 - {用户 - 评分} 的嵌套字典 iu defaultdict(dict) for uid, bid, rat in ratings: iu[bid][uid] rat return dict(iu) ratings load_ratings(book_ratings.csv) item_user build_item_user(ratings) print(f共 {len(ratings)} 条评分涉及 {len(item_user)} 本书)这段代码里有三个值得展开的点。第一next(reader, None)负责吃表头如果 CSV 没表头会静默跳过不会报错比硬写next(reader)更抗脏数据。第二ValueError 分支属于“宁丢勿错”一条评分格式坏了不影响全量文件读取真遇到大量异常再回头查文件编码问题CSV 用 GBK 编码的中文数据是高频翻车点。第三build_item_user返回的是普通 dict 而不是 defaultdict否则后面做item_user[book_id]查找时访问不存在的 key 会悄悄写入空字典把内存撑大。数据准备阶段不要急着写算法先用三个数字自检评分总数、用户数、图书数。评分总数除以用户数低于 5说明数据太稀后面调 min_support 参数时要更保守。4. Python 实现图书推荐的核心代码相似度、TopK 邻居与 TopN 推荐4.1 三种相似度计算余弦、皮尔逊、杰卡德怎么选ItemCF 的灵魂在相似度计算三种主流算法各有脾气。余弦相似度是最常用的起步方案它把两个用户的评分向量看成空间中的向量求夹角余弦值数值落在 -1 到 1 之间。它的优点是实现简单缺点是它对“两个用户都只打高分”的情况不敏感——甲喜欢打 5 星乙也喜欢打 5 星两人其实没有太多共同口味但余弦值会偏高。处理办法是评分去均值也就是皮尔逊相关系数。皮尔逊相关系数本质上是“去均值后的余弦”它扣掉了用户自身的打分习惯在理论上更适合图书这种偏好型评分。但它有个致命弱点需要足够的共同评分用户才能稳定共同评分人数少于 5 时皮尔逊值经常在 -1 和 1 之间剧烈抖动比余弦更不靠谱。杰卡德相似度走的完全是另一条路它不看评分值只看两个集合的交并比。如果一个系统只有隐式反馈用户收藏了、点击了但没有打分杰卡德是最合适的选择但图书推荐系统一般有 1 到 5 的显式评分直接降级成 0/1 会浪费大量信息所以不推荐作为主算法。我的默认选择是共同评分人数少少于 10用余弦共同评分多、评分分布有明显偏置时切皮尔逊。工程上更稳妥的是“先算余弦、再按共同评分人数做加权”后面 4.2 的代码就是按这个思路实现的。4.2 完整实现代码与参数说明下面的代码可以在本地直接跑通一个最小可用的 ItemCF 图书推荐系统核心分三步算相似度矩阵只保留每条物品最像的 TopK 个邻居、聚合用户已读书籍的邻居评分、输出 TopN 推荐。import math from collections import defaultdict from load_data import load_ratings, build_item_user # 复用第3章的代码 def cosine_sim(ratings_i, ratings_j): 两个评分字典的余弦相似度输入 {user_id: rating} common set(ratings_i) set(ratings_j) if not common: return 0.0 dot sum(ratings_i[u] * ratings_j[u] for u in common) norm_i math.sqrt(sum(r ** 2 for r in ratings_i.values())) norm_j math.sqrt(sum(r ** 2 for r in ratings_j.values())) if norm_i 0 or norm_j 0: return 0.0 return dot / (norm_i * norm_j) def build_item_sim(item_user, top_k10, min_support2): 构建物品相似度矩阵只给每个物品保留 top_k 个最相似的邻居 items list(item_user.keys()) sim defaultdict(dict) for idx, item_i in enumerate(items): for item_j in items[idx 1:]: common_count len(set(item_user[item_i]) set(item_user[item_j])) if common_count min_support: continue s cosine_sim(item_user[item_i], item_user[item_j]) if s 0: sim[item_i][item_j] s sim[item_j][item_i] s for item_i in sim: sim[item_i] dict( sorted(sim[item_i].items(), keylambda x: x[1], reverseTrue)[:top_k] ) return dict(sim) def recommend(user_id, item_user, item_sim, top_n10): 为指定用户生成图书推荐返回 [(book_id, 预测分, 评分人数)] # 找出用户已评分过的书及对应分数 rated {bid: rat for bid, rat in item_user.items() if user_id in item_user[bid]} if not rated: return [] scores defaultdict(float) norm defaultdict(float) for bid, rat in rated.items(): for neighbor_bid, s in item_sim.get(bid, {}).items(): if neighbor_bid in rated: # 已读的书不重复推荐 continue scores[neighbor_bid] s * rat norm[neighbor_bid] abs(s) results [] for bid, raw_score in scores.items(): final_score raw_score / norm[bid] if norm[bid] 0 else 0.0 results.append((bid, final_score, len(item_user[bid]))) results.sort(keylambda x: (x[1], x[2]), reverseTrue) return results[:top_n] if __name__ __main__: ratings load_ratings(book_ratings.csv) item_user build_item_user(ratings) item_sim build_item_sim(item_user, top_k10, min_support2) recs recommend(user_id1, item_useritem_user, item_simitem_sim, top_n10) for rank, (bid, score, cnt) in enumerate(recs, 1): print(f#{rank} 图书ID{bid} 预测分{score:.3f} 评分人数{cnt})代码里最需要读懂的三个地方。第一build_item_sim是双重循环复杂度是物品数的平方物品超过 1 万本时会明显变慢所以先用min_support把没有足够共同用户的物品对直接剪掉再进入余弦计算。min_support2意味着至少两个用户同时评过这两本书才考虑算相似度这个值越大相似度矩阵越稀疏、越省内存但也越容易漏掉长尾书。第二recommend的排序用了(x[1], x[2])双字段预测分相同时评分人数多的书排前面。这是给结果增加一点“群体背书”的权重不是纯粹按预测分排序。如果你希望冷门书有更多曝光机会可以把x[2]前的正号改成负号让评分人数少的优先两个方向都值得实验。第三norm[bid] abs(s)是对负相似度的处理。理论上相似度可能为负意味着“喜欢 A 的人讨厌 B”这时评分累加会出现正负抵消。取绝对值做分母是为了避免候选书被一两个负相似邻居拉到底实际项目中如果你发现预测分全是负数可以先做一步去均值皮尔逊再进余弦。4.3 离线评估用精确率、召回率、覆盖率说话推荐系统没有离线指标就像盲飞。最常见的评估方式是随机把评分数据切成训练集和测试集比如按 8:2 切在训练集上算相似度、在测试集上验证“用户真实读过某本书推荐列表里是否包含它”。import random from collections import defaultdict def train_test_split(ratings, test_ratio0.2, seed42): random.seed(seed) train, test [], [] for row in ratings: if random.random() test_ratio: test.append(row) else: train.append(row) return train, test def evaluate(train, test, top_k10, min_support2, top_n10): item_user build_item_user(train) item_sim build_item_sim(item_user, top_ktop_k, min_supportmin_support) user_test_items defaultdict(set) for uid, bid, _ in test: user_test_items[uid].add(bid) hits, recommend_count 0, 0 total_test_items sum(len(v) for v in user_test_items.values()) for uid, test_items in user_test_items.items(): recs recommend(uid, item_user, item_sim, top_ntop_n) rec_ids {bid for bid, _, _ in recs} hits len(test_items rec_ids) if recs: recommend_count 1 precision hits / (recommend_count * top_n) if recommend_count else 0.0 recall hits / total_test_items if total_test_items else 0.0 return precision, recall train, test train_test_split(ratings, test_ratio0.2) prec, rec evaluate(train, test, top_k10, min_support2, top_n10) print(fPrecision{10} {prec:.4f}, Recall{10} {rec:.4f})这个评估有两个常见误区需要说清楚。第一随机切分会让同一个用户的评分一部分在训练集、一部分在测试集测试集里的“待预测行为”和训练集里的“用户偏好”存在信息泄漏所以指标通常会偏高。更严格的做法是按时间切分用前 80% 时间段的评分训练用后 20% 预测这更接近线上真实环境。第二精确率高不代表推荐有用因为 TopN 可能全是热门书——测试样本里热门书占比本来就高蒙也能蒙中。所以要加第三个指标覆盖率推荐列表中出现的不同图书数除以总图书数。覆盖率长期低于 5%说明推荐系统已经在给用户反复推同一个热门书池这时候该检查热门商品权重了。5. 避坑图书推荐系统跑通后最容易翻车的四个细节5.1 相似度全是 1.0推荐结果却没法看现象打印相似度矩阵时发现大量图书对的相似度等于 1.0但推荐结果完全不符合常识——读了《时间简史》的人被推荐了《母猪产后护理》。原因min_support设成了 1也就是只有一个人同时评分过两本书就算相似。一个人的共同评分说明不了任何问题余弦相似度在两个向量的非零元素完全重合时天然等于 1这个 1 是假信号。解决把min_support至少提到 2 或 3只保留“至少被 N 个用户共同评分”的图书对。同时建议在build_item_sim里加一个无限近似判断——当两本书共同评分数是 1 且评分值完全相同时直接丢弃。这个小分支能拦截掉大量无效相似对。5.2 热门书霸榜长尾图书在 TopN 里永远缺席现象推荐列表翻来覆去就是《红楼梦》《三体》《活着》这几本换成谁来测都是同一批结果。原因热门书被大量用户评分天然容易和其他书产生共同评分所以相似度矩阵里它们的朋友圈最大聚合时被邻居引用的次数也最多。这不是算法 bug而是统计偏差。解决参考搜索引擎的词频逆文档频率思想对热门图书的相似度做降权——先把每本书的评分人数归一化再对相似度乘以一个权重因子1 / log(1 评分人数)。降权后冷门书的相似关系才有机会浮出水面。另外推荐列表里刻意留一个“随机探索位”每 10 条推荐中固定插入 1 条从长尾中随机抽的书可以在保持体验的同时缓解马太效应。5.3 离线精确率 0.3上线点击却为零现象离线评估时 Precision10 在 0.25 以上看起来不错上线后用户参与度却极低。原因离线评估用的是“用户确实读过的书”而热门书被读的概率本来就高所以离线精确率高是蒙中的结果。线上用户面对的是没读过的书推荐结果虽然命中了他的阅读历史但新奇度和个性化不足点击意愿自然低。解决先把离线评估从随机切分换成时间切分让测试集更接近真实预测场景然后增加覆盖率和新颖度两个指标覆盖率低于 10% 直接判定为“热门榜复读机”。上线后先做小流量随机对照拿推荐结果和“按热度排序”的基线比点击率而不是只盯着离线指标。记住一句话离线指标是筛选器不是目标函数。5.4 调大 TopK 后内存爆炸进程被直接 kill现象为了让推荐更丰富把top_k从 10 调到 50程序跑到一半内存飙升然后进程被系统杀掉。原因build_item_sim里虽然只保留了每个物品的 TopK 邻居但计算过程中sim[item_i][item_j] s是全量写入的——双重循环遍历了所有物品对只有到函数末尾才截断成 TopK。物品数 2 万时中间状态的物品对就有 2 亿条 Python 字典条目内存根本扛不住。解决不要先全量再截断要在循环内维护一个小顶堆每算出一个相似度就只保留当前最大的 K 个让内存占用始终是 O(物品数 × K) 而不是 O(物品数²)。更取巧的方案是分段计算把物品列表切成多个块逐个块读取时保留历史最大值虽然速度慢一点但内存可控。6. 调参顺序、冷启动补救与上线前该做的验证先把参数调校顺序固定下来避免乱调。第一步固定min_support5这一步过滤无效相似对是推荐质量的基石第二步调top_k从 5 开始每次翻倍观察覆盖率变化覆盖率开始下降的位置就是合适的 TopK第三步调top_n这是产品层面的数推荐位不足 10 个时调它没有意义。整个调参节奏应该是“先稀疏后丰富”不要一上来就追求邻居多。冷启动问题在图书场景躲不掉新书上架没有任何评分ItemCF 对它的相似度为 0。补救方案是用图书元数据做内容兜底把作者、出版社、分类标签转成 one-hot 向量用内容相似度代替协同过滤相似度。新书有了“内容邻居”之后再随评分数据积累逐步切换到协同过滤。用户侧冷启动相对简单新用户没有评分时直接用热门榜填充前三屏拿到 3 到 5 条评分后再切回个性化推荐。上线前至少做三轮验证。第一轮离线指标覆盖精确率、召回率、覆盖率三项第二轮人工巡检随机抽 50 个用户看推荐列表重点排查“已读书被反复推荐”和“同一系列书占比过高”两类问题第三轮小流量对照拿 ItemCF 和热度排序各分 50% 流量跑一周看点击率和平均阅读时长。只有第三轮通过才算真正完成这个系统。这也是我个人的习惯——先做人工抽检再谈指标因为指标会骗人列表不会。我第一次跑通这个项目时Top10 里挤了六个不同版本的《红楼梦》问题不在算法而在数据里评分人数最多的就是它。从那以后我每个推荐列表都先人工过一遍再信指标。希望帮到你。本文还有配套的精品资源点击获取