简介一份基于商品的协同过滤算法实现的图书推荐系统完整工程面向计算机相关专业学生、毕设开发者及推荐算法初学者。项目依托豆瓣图书与评分数据基于items协同过滤思想设计推荐逻辑以Django搭建Web框架并配合MySQL存储从数据读取、相似度计算到推荐展示形成闭环。压缩包共52个文件以Python源码、编译后的pyc、HTML模板、CSS样式及XML配置为主体积仅797KB目录包含后台管理、登录、推荐展示等页面模块结构清晰便于直接导入运行与二次改造。已有78人学习下载。除工程本身外附有README说明文档代码经作者测试可正常运行适合作为课程设计、毕业设计或推荐系统入门实践参考也可在此基础上扩展评分来源、优化相似度算法或对接前端展示。1. 基于商品的协同过滤图书推荐不读懂书也能推荐同类的系统用户刚读完《深入理解计算机系统》第二天页面推荐了《程序员的自我修养》他没给任何标签系统判断的依据只是另一批读者同时翻过这两本书。这正是 Python 基于商品的协同过滤算法图书推荐系统的典型场景不用理解书的语义只凭“商品”维度的用户行为就能把推荐理由讲得像真人导购。这个方向通常会把源代码、数据集和文档说明一起打包交付适合两类人一类是课程设计或毕业设计需要能跑通、能改参、能说清原理的完整项目另一类是已经在做推荐业务的工程师想在图书这类长尾商品上对比 ItemCF 和热门榜的增量。我按自己接手这种项目时的思路往下写先确认选型再处理数据再写算法最后讲到哪里会翻车。2. 为什么图书推荐默认选 ItemCF用户维度的三个现实差异2.1 两种协同过滤的差异和选型判断基于用户的协同过滤先找到和当前用户口味相似的用户群再把这个群喜欢的书推荐过来。基于商品的协同过滤则反着来先算书与书的相似度再根据用户最近评过的书去召回相似书。在图书站后者通常更合适原因有三点。第一规模差异用户量往往比藏书量高一个数量级按用户维度维护近邻列表更新的计算量要远大于按商品维度。第二可缓存性书与书之间的相似度来自一批长期稳定的共同读者变动的速度很慢完全可以每天或每周离线算一次而用户画像会随每次点击变化要求在线计算才能保证实时。第三解释性给出一本《三体》的推荐理由是“看过《球状闪电》的读者也看过”用户能直接理解而 UserCF 的“和你相似的人在看”需要用户先信任这个画像。实际选型时还要看商品数量和用户交互密度。社区电商早期商品只有几千个ItemCF 的相似度矩阵可能不到几十 MBCPU 都能扛一旦商品膨胀到几十万就得用稀疏矩阵和 TopK 剪枝这个我在第 4 章会展开。另一个判断点是业务要不要“跟兴趣漂移”。读者去年还在买编程书今年开始买育儿书UserCF 的相似用户还是那批程序员推荐就跑偏了ItemCF 只看当前最近评分过的几本书跟着行为走反而更好跟上变化。| 对比维度 | UserCF | ItemCF | | 计算规模 | 用户数大实时压力高 | 商品数相对小可离线 | | 解释性 | 相似用户解释弱 | 看过也看过解释强 | | 兴趣漂移 | 旧画像滞后 | 跟随近期行为 | | 冷启动 | 新用户难新商品可救 | 新用户可借热门新商品难 | | 常见场景 | 新闻、社交 | 图书、电商 |可以把这条表格当成选型时说服同事的检查单。真正动手前先看商品量级和交互密度商品数明显小于用户数、推荐理由需要很强的可解释性基本就锁死 ItemCF。如果做的是今日头条式的资讯流用户的兴趣是即时且短期的UserCF 反而更合适因为新闻的生命周期太短物品相似度缓存跟不上热点。2.2 ItemCF 的落地主流程四个阶段第一阶段数据清洗与评分标准化去掉只有一两个评分的书和用户把显式评分减去用户均值避免打分严格的人吃亏。第二阶段构建依据共同读者的相似度把用户-图书评分矩阵转成列向量用余弦或调整余弦算两两相似度。第三阶段邻居选择对每个物品保留相似度最高的 K 个近邻K 常见值 20 到 80。第四阶段推荐生成把用户评分过的物品作为种子累加各近邻的得分过滤掉已读过的取 TopN。这些阶段对应着代码包里的数据预处理模块、相似度模块、推荐模块和评估模块文档说明一般也会按这四个模块组织。第三阶段是新手最常忽略的。如果不剪枝一本《三体》会和所有书都有相似度虽然很多是 0.01 的噪声但会让推荐列表被热门书淹没。常见做法是只保留每个物品相似度最高的 K 个邻居把其他项直接置零。这个 K 不要超过 100否则后续累加评分时计算量和噪声都会变大。注意这里 K 和最终推荐给用户的 TopN 是两回事前者控制模型形状后者控制输出长度。2.3 相似度公式怎么选余弦、皮尔逊与调整余弦的取舍余弦相似度直接算两个评分向量夹角的余弦但每个人打分习惯不同有人给喜欢的书打 4 分、不喜欢的打 2 分有人喜欢也打 5 分、不喜欢也打 3 分原始分做点积会被这种个人尺度带偏。皮尔逊相关系数通过减去用户自己的平均分来消除尺度差异在评分数据上更合理但在图书这种稀疏数据里两个用户只有一次共同评分时相关系数恒为 1噪声很大。调整余弦是折中先减用户均值再做余弦计算它比原始余弦更抗评分偏置又比皮尔逊稳定。import numpy as np def adjusted_cosine(items_a, items_b, user_mean): items_a / items_b: dict[user_id] rating common set(items_a) set(items_b) if len(common) 2: return 0.0 a np.array([items_a[u] - user_mean[u] for u in common]) b np.array([items_b[u] - user_mean[u] for u in common]) return float(a b) / (np.linalg.norm(a) * np.linalg.norm(b))这个函数把共同评分的用户找出来分别减去用户历史均值再算余弦。len(common) 2直接返回 0 是血泪经验只出现一次共同评分时两个向量各自只有一个非零分量归一化后相似度必然是 1对模型没有任何区分度还会把噪声邻居带上岸。如果你用的是点击/收藏这类隐式反馈不一定要减均值因为隐式评分等于 1 时原始余弦和皮尔逊结果差别不大直接用原始余弦更快。3. 图书评分数据怎么准备数据字段、预处理与用户-物品矩阵3.1 最小的三张表设计用户、图书、评分推荐系统真正吃的是评分表但用户表和图书表也不能省冷启动和评估解释都要靠它。最小结构是用户表存 user_id、年龄、职业等静态属性图书表存 book_id、书名、作者、分类评分表存 user_id、book_id、rating、timestamp。市面上常见的图书评分公开数据集无论怎么命名导出后基本都能整理成这三张表。| 表 | 关键字段 | 说明 | | users | user_id, age, occupation | 冷启动、分层评估 | | books | book_id, title, author, category | 解释推荐理由、算多样性 | | ratings | user_id, book_id, rating, timestamp | 算法主输入 |评分表的 timestamp 是新手最容易丢的字段。没有时间就只能随机切分训练测试模型会用未来的行为预测过去离线指标虚高上线后一塌糊涂。数据里如果给的是 UNIX 时间戳先确认单位是秒还是毫秒这直接影响第 3.3 节的切分正确性。3.2 把原始评分表转成用户-物品矩阵的 Python 脚本拿到原始 CSV 之后第一步不是建模而是过滤和编码。直接按原始 user_id 和 book_id 做矩阵行号和列号会跳得很厉害稀疏矩阵的 shape 也会虚大。下面这个脚本是这类项目最常见的开头。import pandas as pd from scipy.sparse import csr_matrix ratings pd.read_csv(ratings.csv) # user_id, book_id, rating, timestamp # 过滤冷门商品和低活跃用户阈值先按 5 试 book_cnt ratings.groupby(book_id)[user_id].count() keep_books book_cnt[book_cnt 5].index ratings ratings[ratings[book_id].isin(keep_books)] user_cnt ratings.groupby(user_id)[book_id].count() keep_users user_cnt[user_cnt 5].index ratings ratings[ratings[user_id].isin(keep_users)] # 重新编码成连续的整数索引 user_ids {uid: i for i, uid in enumerate(ratings[user_id].unique())} book_ids {bid: i for i, bid in enumerate(ratings[book_id].unique())} ratings[u_idx] ratings[user_id].map(user_ids) ratings[b_idx] ratings[book_id].map(book_ids) # 构建稀疏矩阵行是用户列是图书 mat csr_matrix((ratings[rating], (ratings[u_idx], ratings[b_idx]))) print(mat.shape, mat.nnz)逻辑顺序是先按商品过滤再按用户过滤。这样先去掉只有一两次评分的书避免后续相似度计算被无效共现干扰。csr_matrix的三个参数分别是值、行索引、列索引它会自动把重复位置的值相加所以不要传已经排序好的长表。过滤阈值 5 不是固定值数据量大可以提到 10数据量小降到 3图书评分数据通常很稀疏我一般从 5 起步。这里有个老生常谈的坑不要调mat.toarray()。评分矩阵行数等于用户数、列数等于图书数转成稠密数组后一个一万用户一万本书的矩阵就是 1 亿个 float接近 800 MB。后续所有相似度计算都建立在稀疏矩阵上只有排查小样例时才允许toarray()。3.3 隐性反馈、评分偏置与训练集切分图书推荐有两类信号。显式反馈是用户打的 1 到 5 分信息量足但数量少隐式反馈是浏览、收藏、加入书架量大但噪声大。ItemCF 两种都能吃显式反馈直接用评分隐式反馈统一记成 1。混合使用时最好把评分和隐式信号分开建用户-物品矩阵推荐阶段再做加权融合直接拼一起会让“收藏过但没读过”的书喧宾夺主。评分偏置是显式数据特有的问题。有人只给 3 到 4 分有人偏好 1 到 5 分全用原始分做相似度会被个人尺度左右。常见做法是在构造矩阵前把每条评分减去当前用户的历史平均分得到调整评分。这步和 2.3 节调整余弦呼应无论是算相似度还是后面累加候选得分都要用调整后的值。ratings[adj_rating] ratings[rating] - ratings.groupby(user_id)[rating].transform(mean) ratings[rank] ratings.groupby(user_id)[timestamp].rank( methodfirst, ascendingFalse ) test ratings[ratings[rank] 1] train ratings[ratings[rank] ! 1]逻辑说明rank按每个用户分组把最近一次行为标记为 1其余进训练集。这样每个用户只有一条测试记录离线评估好算也不会出现时间穿越。methodfirst是为了处理同一秒里有多条行为的情况给相同时间戳一个确定的先后顺序。不要用随机抽样切分随机切分会同时让训练和测试各拿一半评分等于允许用未来的评分预测过去的评分离线指标会明显虚高。注意如果数据里隐式反馈占大头训练集切分可以按“最近 N 天做测试”而不是“每个用户一条”否则测试集太小recall 算出来接近 0。4. 用 Python 实现 ItemCF 图书推荐相似度矩阵与 Top-N 推荐4.1 构建商品相似度矩阵暴力法和共现矩阵法最直观的写法是双重循环逐个商品对找共同用户算相似度。图书几万本时这个 O(商品数平方) 的循环还能忍受到几十万本跑一晚上都出不来。实际项目里更常见的做法是矩阵乘法先把用户-商品矩阵按列归一化再乘它的转置每个位置就是两本商品的余弦相似度。from sklearn.preprocessing import normalize import scipy.sparse as sp # train_df 来自 3.3包含 u_idx, b_idx, adj_rating uim sp.csr_matrix((train_df[adj_rating], (train_df[u_idx], train_df[b_idx]))) # axis0 是对每一列做归一化每一列代表一本图书 item_mat normalize(uim, norml2, axis0) sim_mat (item_mat.T item_mat).tocsr() # 相似度过低的直接置零减少后面推荐阶段的噪声 sim_mat.data[sim_mat.data 0.1] 0 sim_mat.eliminate_zeros()逻辑说明normalize(..., axis0)把每个商品的评分向量变成单位向量这样矩阵乘的结果就是余弦相似度。sim_mat.data[sim_mat.data 0.1] 0是第一次剪枝阈值 0.1 是我在评分数据里常用的起始值如果用的是隐式反馈 0/1 数据阈值放到 0.05 更合适。eliminate_zeros()必须调用否则稀疏矩阵里会保留大量显式零后面的 TopK 剪枝和存储都会变慢。真正生产环境还要再做一次 TopK 剪枝只保留每行最大的 K 个相似度。直接对稀疏矩阵做逐行处理即可K 一般取 50。import numpy as np def keep_topk(sim, K50): sim sim.tocsr() rows, cols, data [], [], [] for i in range(sim.shape[0]): start, end sim.indptr[i], sim.indptr[i1] vals sim.data[start:end] col_idx sim.indices[start:end] if len(vals) K: cut np.argpartition(vals, -K)[-K:] vals, col_idx vals[cut], col_idx[cut] rows.extend([i] * len(vals)) cols.extend(col_idx) data.extend(vals) return sp.csr_matrix((data, (rows, cols)), shapesim.shape) sim_mat keep_topk(sim_mat, K50)这个循环对每个商品取相似度最高的 K 个邻居。np.argpartition不是全排序而是只把前 K 大的元素找出来比直接argsort快很多。剪枝后矩阵的非零元素数量最多是商品数 * K内存占用从平方级降到线性级。如果跑的时候还是慢先看是不是忘了eliminate_zeros()这是最常见的翻车点。4.2 生成 Top-N 推荐列表的完整函数相似度矩阵算完后推荐阶段要做三件事找种子、累加候选、过滤已读。下面的函数直接对应这三件事输入是用户历史评分和剪枝后的相似度矩阵输出是推荐的 book_id 列表。def recommend_for_user(user_history, sim_mat, book_index, item_reverse, K50, N10): # user_history: {book_id: 调整评分} # book_index: {book_id: 矩阵列号} # item_reverse: {矩阵列号: book_id} seed_set set(user_history.keys()) scores {} for book_id, r in user_history.items(): idx book_index[book_id] neighbors sim_mat[idx].tocoo() for col, s in zip(neighbors.col, neighbors.data): if col idx: continue neighbor_book item_reverse[col] if neighbor_book in seed_set: continue scores[neighbor_book] scores.get(neighbor_book, 0.0) s * r ranked sorted(scores.items(), keylambda x: -x[1])[:N] return [book_id for book_id, _ in ranked]逻辑说明s * r表示种子图书的评分越高它邻居的候选得分就越高。累加而不是取最大值是为了综合多本历史图书的意见取最大值容易被单本书的噪声邻居带偏。过滤已读图书是必须的否则用户刚看完的书会原封不动出现在下一次推荐里。sim_mat[idx].tocoo()取出这本书的所有近邻列号转回原始 book_id 要用item_reverse因为矩阵里的索引只是编码。实际工程里不会每次请求都现算常见做法是给每个用户离线算好 TopN 列表写进 Redis 或数据库用户产生新评分后再做一次增量计算。这里给出的函数适合评估脚本和毕设演示直接对测试集逐用户调用。4.3 三个必调参数近邻数 K、最小共现阈值、评分归一化| 参数 | 作用 | 起始经验值 | | 近邻数 K | 控制每本图书的邻居数量影响覆盖和噪声 | 50范围 20~80 | | 最小相似度阈值 | 过滤相似度很低的伪邻居 | 评分数据 0.1点击数据 0.05 | | 评分归一化 | 消除用户打分尺度差异 | 减去用户均值 |调参顺序我习惯先固定 K50扫最小相似度阈值看推荐结果的 precision 和覆盖率。阈值设太高候选物品变少覆盖下降阈值设太低热门书噪声进来。K 的影响相反K 越大覆盖越广但精度通常先升后降。评分归一化这个参数不用扫直接减均值就行如果你用的是隐式反馈归一化改成“全部置 1”。这三个参数在文档说明里最好有一份“实验记录”写明数据集规模、阈值和对应指标。因为图书数据集不同同样 K50 在评分密集的公开数据集上表现很好在只有几万人互动的数据集上就可能候选为空。参数没有玄学只有跑完才知道。5. 离线评估与常见问题排查指标没涨时先查这五个位置5.1 精确率、召回率、覆盖率与多样性如何离线算评估要跟着使用场景走。用第 3.3 节的时间切分测试集每个用户只有一条最近行为这时候最合适的指标是 precisionN 和 recallN。精确率等于“推荐的 N 本书里有多少被用户真正读了”召回率等于“用户读过的书里有多少被推荐出来”。图书场景一次只测一个行为recall 会偏低这很正常。def evaluate(reco_func, train_df, test_df, N10): hits 0 num_rel 0 num_pred 0 recommended_pairs [] for uid, group in test_df.groupby(user_id): rel set(group[b_idx]) rec reco_func(uid, NN) hits len(rel set(rec)) num_rel len(rel) num_pred N recommended_pairs.extend(rec) precision hits / num_pred recall hits / num_rel coverage len(set(recommended_pairs)) / train_df[b_idx].nunique() return precision, recall, coverage逻辑说明reco_func的签名是输入用户 id 和 N返回推荐列表。这里用 b_idx 而不是 book_id是为了方便在稀疏矩阵里直接查索引。覆盖率是推荐出的不同图书数除以训练集图书总数这个指标一定要看。如果覆盖率很低说明算法退化成热销榜精确率再高也没有增量价值。多样性通常在人工评估阶段看离线可以用推荐列表里不同分类的书占比粗算。5.2 排查一热门图书霸榜推荐结果像热销榜现象推荐列表里《三体》《活着》这种大众书占了七八个用户反馈“这不是推荐是排行榜”。原因ItemCF 累加阶段热门书作为候选会被很多种子图书反复提到即使单次相似度不高累加次数一多得分就上去了。解决在最终得分里加一个基于图书流行度的惩罚项。scores[neighbor_book] scores[neighbor_book] / np.log1p(popularity[neighbor_book])popularity可以是每本书在训练集里的评分次数log1p是为了不让特别热门的书被惩罚得过头。这个惩罚项也可以在相似度构建阶段做但放在推荐阶段更灵活可以分策略开关。加完惩罚后重新看覆盖率通常会明显上升。5.3 排查二评分矩阵稀疏相似度全是零现象sim_mat的相似度大部分为 0给用户推荐时候选只有三五本甚至返回空。原因图书评分数据天然稀疏很多书只有一两条评分和其他书没有共同用户。解决先提高 3.2 节的过滤阈值把评分次数少于 5 的书去掉再把点击、收藏这些隐式行为转成评分 1扩大共现信号。如果还不行说明数据量实在不够暂时用图书的作者和分类做内容相似度兜底等用户行为积累到一定量再切回 ItemCF。5.4 排查三新用户没有历史行为推荐函数返回空现象新注册用户请求推荐接口返回空列表前端只能显示“暂无推荐”。原因recommend_for_user依赖用户历史评分作为种子新用户没有任何行为scores为空。解决冷启动阶段退化为热门榜兜底比如整体评分次数最高的前 50 本书等用户攒了 3 本以上评分后再切换为 ItemCF。切换时不要把热门榜直接清空建议一半热门榜、一半 ItemCF 混合一段时间避免用户觉得推荐效果突然变了。5.5 排查四内存爆炸相似度矩阵比训练数据还大现象图书 10 万本时item_mat.T item_mat如果转成稠密矩阵需要 40 GB直接 OOM。原因相似度矩阵本质是平方级全量稠密存储不可行。解决从第一步就用稀疏矩阵构造完立刻keep_topk剪枝最后只保存每本书的 TopK 邻居三元组不保存完整矩阵。如果单次计算还是太大就分块做for start in range(0, n_items, 1000): block (item_mat[:, start:start1000].T item_mat).tocsr() # 剪枝后写成 (item_i, item_j, sim) 的 csv / parquet分块大小根据内存设1000 到 5000 都行。块内剪枝后写盘最后加载时再拼成稀疏矩阵。这个方案牺牲了一点全局精确性但能处理几十万本图书的规模。6. 让推荐结果更耐看的进阶技巧时间衰减与增量更新6.1 时间衰减让老数据慢慢褪色图书推荐的隐性时间问题比电商还严重一本书三个月前的火爆和今天的流行度完全不是一回事。给评分加时间衰减最直接的做法是decayed_rating rating * exp(-lambda * age_days)lambda 取 0.005 到 0.02时间越久权重越低。这个衰减在切分训练集之后、构建相似度矩阵之前做只作用于训练集。它不能解决“热书霸榜”但能让半年前的用户行为不再持续拉高老书的相似度。6.2 模型增量更新每天只算增量相似度矩阵如果每天晚上全量重算一次图书量到 20 万后即使稀疏剪枝也要占用不少任务时间。常见做法是只在有新书或评分明显增长时做增量新书上架时只算它和所有旧书的相似度旧书之间相似度不是每次重算而是保留历史值每周做一次全量修正。这样凌晨任务从全量小时级降到增量分钟级业务上完全够用。6.3 验证推荐质量的最简单办法最后分享一个我自己吃亏换来的检查习惯每次调完参不看平均指标先随机抽 10 个活跃用户把他们的历史评分 Top5 和推荐 Top10 打出来人工读一遍。你会发现很多指标发现不了的问题比如推荐列表里全是续集、同系列书或者把所有烹饪书一股脑推给只看过一本烘焙书的人。这个动作虽然原始但它是评估脚本之外最可靠的“推荐到底像不像人话”的验证方式。希望帮到你。本文还有配套的精品资源点击获取