
简介本资源是一套面向本科毕业设计的基于协同过滤算法的图书推荐系统完整实现适用于Python初学者及推荐系统入门学习者解决海量图书场景下个性化内容发现难题。压缩包共46个文件包含7个核心Python脚本如itemcf.py、models.py、app.py、7个HTML页面含用户/管理员双端界面、21个JS与6个CSS文件支撑前端交互与样式以及SQLite数据库.db和相似度矩阵等关键数据文件整体仅985KB轻量易部署。已有2709人学习下载覆盖从数据预处理、物品相似度计算余弦相似度、评分预测到Flask后端接口与响应式前端的全流程代码结构清晰、模块职责分明特别适合毕设快速搭建与算法原理验证。 很多人问我图书推荐系统应该怎么入门。如果只选一个算法作为起点我建议先做协同过滤。原因很直接图书数据里用户评分行为相对规范而且协同过滤不依赖对图书内容的解析只要有“用户-图书-评分”三要素就能搭出一个能用的推荐系统。这篇文章就用 Python 完整做一遍从数据处理、相似度计算、评分预测到最后暴露成 HTTP 接口我会把每一步怎么来的、为什么这么写、踩过哪些坑都讲清楚。这个项目适合刚接触推荐系统的同学也适合手里有用户行为数据、想快速验证协同过滤效果的开发者。你不需要提前懂高端数学大学线代里的矩阵乘法就够用但最好会把 pandas 和 numpy 的基础操作。下面进入正题。1. 项目核心思路图书推荐系统为什么用协同过滤1.1 图书推荐场景的特殊性图书推荐和短视频推荐、电商推荐最大的不同在于数据密度。用户一年能看几十本书就算不错了但平台可能有几十万本在库图书评分矩阵天然非常稀疏。在这种场景下协同过滤依然是性价比最高的起点因为它只依赖用户和物品的交互记录不需要额外爬取书名、作者、简介、分类标签这些内容特征。另一个原因是图书的“消费周期”长。用户看完一本《三体》可能要过一两周才会继续产生评分行为所以做离线批处理推荐就够了不需要像新闻推荐那样几秒更新一次。这也让基于 Python 的离线计算套路成为主流不用一上来就上 Spark、Flink 这类重型组件。1.2 协同过滤的两条主线协同过滤的核心假设就一句话相似的人喜欢相似的东西或者相似的东西会被同一个人喜欢。围绕这个假设分成两条技术路线。基于用户的协同过滤UserCF先找到和你口味最像的一群老用户再把他们读过而你还没读过的书推荐给你。更适合用户画像相对稳定、用户数量远小于物品数量的场景。基于物品的协同过滤ItemCF先计算图书之间的相似度再根据你评过高分的书找出同类型的书推荐给你。更适合物品数量相对可控、热点变化快的场景。图书推荐项目里我的判断是 ItemCF 通常更实用。因为图书的“邻居关系”比较稳定比如《人类简史》和《未来简史》长期都是相近的但用户口味会漂移。不过为了把原理讲透下面的代码两种都会实现最后可以对比效果。1.3 技术栈选型别一上来就上深度学习很多人一听到“推荐系统”第一反应就是深度学习、双塔模型、图神经网络。但对于一个数据量在几十万到几百万条级别的图书评分项目这些方案太重了而且效果不一定比经典协同过滤好。我用的技术栈是 Python 3.10 pandas numpy scipy scikit-surprise Flask理由也很简单pandas 负责数据清洗和转换处理评分表这种结构化数据非常顺手。numpy 和 scipy 负责矩阵运算评分矩阵存成稀疏矩阵能省大量内存。scikit-surprise 是一个专门做推荐系统评估的 Python 库内置了多种协同过滤算法和评估指标省去自己写交叉验证的麻烦。Flask 只用来把推荐结果封装成接口方便后续接前端或做服务化部署。这套组合最大的优势是黏合度高。算法部分先手动实现一遍理解每一步在算什么再用 Surprise 库快速做批量实验两边都不会卡住。2. 数据准备从原始评分到用户-图书矩阵2.1 数据集和字段说明图书推荐最常用的公开数据集是 Book-Crossing包含用户 ID、图书 ISBN、评分三个核心字段评分范围是 0 到 10。也可以直接用自己业务里的行为数据只要保证有三列就行。下面这段代码是造一个最简单的样例数据方便跑通流程import pandas as pd import numpy as np ratings pd.DataFrame({ user_id: [1, 1, 2, 2, 3, 3, 4, 4, 5], book_id: [101, 102, 101, 103, 102, 104, 103, 105, 101], rating: [5, 3, 4, 2, 4, 5, 3, 4, 2] })真实项目里不要这么温柔通常还有时间戳、评价文本、是否购买等因素但做协同过滤第一步只需要 user_id、book_id、rating 这三列。后面扩展的时候再考虑把时间衰减加权进去。2.2 数据清洗的四个优先动作拿到原始评分表后第一件事不是算相似度而是清洗。我在实际项目里总结出四个必须做的动作按优先级排去掉评分字段为空的记录Book-Crossing 里有很多“隐式反馈”记录评分填的是 0其实是用户浏览过但没有明确打分如果直接参与计算会拉低预测值。过滤掉评分次数过少的用户比如只评过 1 本书的用户对邻居贡献极小还会让相似度矩阵变稠密。过滤掉被评次数过少的图书比如只有 1 个人评过的书它不构成可靠推荐依据。检查 user_id 和 book_id 是否有脏数据比如空字符串、非法字符这一步容易被忽视但一旦出现pivot_table 会直接报错。清洗代码可以封装成一个函数def clean_ratings(ratings, min_user_count5, min_book_count5): ratings ratings.dropna(subset[user_id, book_id, rating]) ratings ratings[ratings[rating] 0] user_count ratings.groupby(user_id)[book_id].count() book_count ratings.groupby(book_id)[user_id].count() valid_users user_count[user_count min_user_count].index valid_books book_count[book_count min_book_count].index return ratings[ratings[user_id].isin(valid_users) ratings[book_id].isin(valid_books)].copy()这里的 min_user_count 和 min_book_count 是经验值初始设成 5 是比较稳的起点太小会让噪声进来太大又会把有效行为过虑掉。2.3 评分矩阵pivot 与稀疏存储协同过滤的评分矩阵是“行对应用户列对应图书”的二维矩阵。pandas 里用 pivot_table 可以快速转换。rating_matrix ratings.pivot_table(indexuser_id, columnsbook_id, valuesrating)但这里有个问题pivot_table 会把缺失的评分变成 NaN而后面做矩阵运算时需要用 0 表示“没有评分”。直接 fillna(0) 是最简单的做法但要注意把 0 和真实评分 0 区分开。好在经过清洗评分都是正数所以 0 可以安全地代表缺失。当矩阵规模变大时fillna(0) 会产生一个巨大的稠密矩阵内存可能直接崩溃。更稳妥的做法是用 scipy 的稀疏矩阵。但对初学者来说先用稠密矩阵把算法跑通再用稀疏矩阵优化内存是更好的学习路径。2.4 验证数据稀疏度建立矩阵后我通常先算一个稀疏度指标density (rating_matrix.to_numpy() 0).mean() print(f矩阵稀疏度: {density:.4f})如果密度低于 0.01说明平均每个用户只评价了 1% 的图书这时候要考虑是不是过滤阈值设置得太低或者数据本身过于稀疏。图书推荐场景 1% 可能正常但如果只有 0.001%协同过滤的效果会非常差后面需要靠冷启动方案去补。3. 算法实现UserCF 和 ItemCF 的 Python 代码详解3.1 相似度计算余弦和皮尔逊到底怎么选相似度是协同过滤的灵魂。最常用的两种是余弦相似度和皮尔逊相关系数。余弦相似度衡量两个向量在多维空间里的夹角公式是[ sim(u, v) \frac{\sum_i r_{u,i} r_{v,i}}{\sqrt{\sum_i r_{u,i}^2} \sqrt{\sum_i r_{v,i}^2}} ]皮尔逊相关系数本质上是中心化后的余弦相似度先减去每个用户的平均评分再去算夹角[ sim(u, v) \frac{\sum_i (r_{u,i} - \bar{r}u)(r{v,i} - \bar{r}v)}{\sqrt{\sum_i (r{u,i} - \bar{r}u)^2} \sqrt{\sum_i (r{v,i} - \bar{r}_v)^2}} ]直觉上余弦相似度受用户评分尺度影响很大。A 用户习惯给 4 到 5 分B 用户习惯给 1 到 5 分他们可能对同一本书都给出了“相对高分”但余弦算出来相似度很低。皮尔逊通过减去平均值缓解了这个问题。在图书场景里我建议优先用皮尔逊。因为用户评分习惯差异非常明显有人手松有人手紧中心化能明显改善相似度质量。3.2 基于用户的协同过滤核心代码下面这段代码计算用户相似度矩阵关键在于先做均值中心化再做余弦相似度从而实现皮尔逊相关系数def calc_user_similarity(rating_matrix): R rating_matrix.values.astype(float) mask R 0 # 每个用户的平均评分只有有评分的位置参与计算 user_means np.array([ R[i][mask[i]].mean() if mask[i].any() else 0.0 for i in range(R.shape[0]) ]) # 中心化有评分的位置去掉用户平均分无评分的位置保持 0 centered np.where(mask, R - user_means[:, None], 0) # 计算皮尔逊相似度 norm np.linalg.norm(centered, axis1) norm[norm 0] 1e-9 sim centered.dot(centered.T) / np.outer(norm, norm) return pd.DataFrame(sim, indexrating_matrix.index, columnsrating_matrix.index)得到相似度矩阵之后预测用户 u 对图书 i 的评分通常取 top_k 个最相似且对 i 有评分的用户做加权平均def predict_with_usercf(user_id, book_id, rating_matrix, similarity_matrix, top_k20): if user_id not in rating_matrix.index: return np.nan if book_id not in rating_matrix.columns: return np.nan sims similarity_matrix.loc[user_id] book_ratings rating_matrix[book_id] rated_users book_ratings[book_ratings 0].index # 排除目标用户自身 valid_sims sims.loc[rated_users].drop(indexuser_id, errorsignore) valid_sims valid_sims.sort_values(ascendingFalse).head(top_k) if valid_sims.empty or valid_sims.sum() 0: return rating_matrix.loc[user_id].replace(0, np.nan).mean() weights valid_sims.values rated_values book_ratings.loc[valid_sims.index].values return np.dot(weights, rated_values) / weights.sum()这段预测逻辑有两个关键点。第一只取当前用户评过分的哪些书作为候选物品不要拿已经读过的书再推一遍。第二邻居数量 top_k 不能太大图书评分矩阵稀疏取 100 个邻居时很多权重都很小预测值会被稀释一般 20 到 50 比较合适。3.3 基于物品的协同过滤换一个角度ItemCF 的思路是对称的矩阵转置后计算物品相似度矩阵def calc_item_similarity(rating_matrix): R rating_matrix.values.astype(float) mask R 0 # 每本书的平均评分 item_means np.array([ R[:, j][mask[:, j]].mean() if mask[:, j].any() else 0.0 for j in range(R.shape[1]) ]) centered np.where(mask, R - item_means[None, :], 0) norm np.linalg.norm(centered, axis0) norm[norm 0] 1e-9 sim centered.T.dot(centered) / np.outer(norm, norm) return pd.DataFrame(sim, indexrating_matrix.columns, columnsrating_matrix.columns)预测用户 u 对图书 i 的评分时把用户历史评分图书的相似度作为权重对历史评分做加权平均def predict_with_itemcf(user_id, book_id, rating_matrix, item_sim_matrix, top_k20): if user_id not in rating_matrix.index or book_id not in item_sim_matrix.index: return np.nan user_rated rating_matrix.loc[user_id] rated_books user_rated[user_rated 0].index if len(rated_books) 0: return np.nan sims item_sim_matrix.loc[book_id].loc[rated_books].sort_values(ascendingFalse) sims sims.head(top_k) if sims.empty or sims.sum() 0: return np.nan ratings user_rated.loc[sims.index].values return np.dot(sims.values, ratings) / sims.values.sum()ItemCF 在图书推荐里有个天然优势离线计算好书与书的相似度矩阵后线上推荐只需要查表不需要实时计算用户两两相似度。用户量很大时UserCF 的相似度矩阵是 O(N²)而 ItemCF 的矩阵是 O(M²)M 通常远小于 N这也是图书场景选 ItemCF 更实际的重要原因。3.4 用 Surprise 库快速验证手动实现能帮你理解原理但做批量实验和交叉验证时我建议用 Surprise 库代码量能少一个数量级。from surprise import Dataset, Reader, KNNBasic from surprise.model_selection import cross_validate reader Reader(rating_scale(0, 10)) data Dataset.load_from_df(ratings[[user_id, book_id, rating]], reader) sim_options { name: pearson_baseline, user_based: True, min_support: 3 } algo KNNBasic(k40, min_k3, sim_optionssim_options, verboseTrue) results cross_validate(algo, data, measures[RMSE, MAE], cv5, verboseTrue)这里的 min_support 和 min_k 是容易被忽略的参数。min_support 表示两个用户至少有共同评过多少本书才参与相似度计算低于阈值直接置为 0能挡掉很多偶然共现。min_k 表示预测时如果有效邻居不够就直接放弃预测用全局均值兜底。这两个参数在数据稀疏时比 k 本身更重要。4. 模型评估与调优用 RMSE 和召回率说话4.1 指标选择什么时候看 RMSE什么时候看召回率推荐系统离线评估有两个视角。一个是评分预测准确度用 RMSE 和 MAE另一个是 Top-N 推荐质量用 Precision 和 Recall。指标关注点适用场景RMSE预测评分与真实评分的误差大误差会被放大评分预测任务MAE预测评分的平均绝对误差评分预测任务对异常值不敏感PrecisionN推荐列表里真正被用户喜欢的比例Top-N 推荐列表RecallN用户真实喜欢的图书有多少被推荐出来Top-N 推荐列表图书推荐系统更关心 Top-N 结果所以我的习惯是先看 RMSE 做粗筛再用 Recall10 看推荐列表质量。很多项目 RMSE 降了 0.05用户可能毫无感觉但推荐列表里多出一本用户真正想看的书才是实打实的价值。4.2 手动实现一个 RMSE 评估在手动实现的代码里可以用下面的方式切分数据from sklearn.model_selection import train_test_split train, test train_test_split(ratings, test_size0.2, random_state42) train_matrix train.pivot_table(indexuser_id, columnsbook_id, valuesrating).fillna(0) similarity calc_user_similarity(train_matrix) test_pairs test[[user_id, book_id, rating]].values preds [] for user_id, book_id, true_rating in test_pairs: pred predict_with_usercf(user_id, book_id, train_matrix, similarity, top_k40) if not np.isnan(pred): preds.append((pred, true_rating)) rmse np.sqrt(np.mean([(p - t) ** 2 for p, t in preds])) print(fRMSE: {rmse:.4f})这种写法只用来理解评估逻辑。真实项目直接交给 Surprise 的 cross_validate会自动做折叠切分还能拿到标准差。4.3 调参实验k 值、相似度方式、min_support我常用的一组对比实验是这样设计的相似度方式cosine 对比 pearson_baseline。k 值20、40、60。min_support1、3、5。每个组合跑一次 5 折交叉验证记录 RMSE 和 MAE。做完后你会发现调高 min_support 对 RMSE 的提升往往比调 k 还明显。原因嘛就是图书评分太稀疏共同评分不足的“伪相似”用户只会引入噪声。另外不要只看指标均值还要看跑了多长时间。如果 min_support5 能把相似度矩阵的稠密度降到 1%内存占用和计算时间会大幅下降那即使 RMSE 稍微高一点点也值得优先选。5. 落地服务把推荐逻辑封装成 Flask 接口5.1 离线模型和在线服务怎么分工推荐系统上线时不需要在用户请求时实时重算整个矩阵。更常见的做法是离线脚本定期训练模型把相似度矩阵或 Top-N 推荐结果存下来线上服务只做查表和结果组装。我在这个项目里的结构是每天凌晨跑一次训练脚本生成用户相似度矩阵或物品相似度矩阵。推荐结果写进 Redis 或本地缓存比如rec:user:12345。Flask 接口收到请求后先查缓存没有再实时调用预测函数。这样做的好处是接口响应时间能控制在几十毫秒不会因为一次矩阵乘法把 CPU 打满。5.2 一个能用的推荐接口下面是一段完整的 Flask 接口示例省略了模型训练细节只展示结构from flask import Flask, jsonify, request import pandas as pd app Flask(__name__) # 全局变量模型训练一次后常驻内存 model None def train_model(): global model ratings pd.read_csv(ratings.csv) # 训练逻辑生成 model # model UserCFModel(ratingsratings) pass app.route(/api/v1/recommend, methods[GET]) def recommend(): user_id request.args.get(user_id, typeint) if not user_id: return jsonify({error: user_id is required}), 400 try: books model.recommend(user_id, top_n10) except KeyError: books [] return jsonify({ user_id: user_id, recommendations: books }) if __name__ __main__: train_model() app.run(host0.0.0.0, port8000)接口里的模型对象最好用单例模式训练可以做成后台脚本不必每次启动都重新算。这种设计够应付大多数中小型图书推荐项目。5.3 上线前要补的优化点如果只做离线推荐能跑通就行。但要想真正上线我建议至少补上这三件事推荐结果去重过滤用户已经看过的书。使用评分人数做加权避免只有 2 个人给出 10 分的小众书排到太前面。加一层热门回退新用户没缓存时返回全站热门图书。热门图书的计算也别只按平均分排序。一个只有 3 个人评 10 分的书排在 3 万人评 8 分的书前面对产品是灾难。可以用简单的贝叶斯平均分[ score \frac{C \times m \sum r}{C count} ]其中 m 是全局平均评分C 是调节常数一般取 20 到 50。这样评分人数越少分数越向全局均值靠拢能有效压制“小众高分”噪声。6. 常见问题与踩坑记录6.1 新用户和新书完全没推荐结果怎么办协同过滤的冷启动是绕不开的。新用户没有任何评分相似度矩阵里找不到邻居预测函数只能返回 NaN。我在项目里给到的方案是分层回退第一层有缓存的用户直接返回个性化推荐。第二层没有缓存的用户返回全站热门榜。第三层针对新上架的图书用图书分类、作者、关键词做基于内容的相似候选。不要想着一个算法解决所有问题。简单可靠的兜底策略比强行灌入随机推荐好得多。6.2 内存爆掉相似度矩阵根本放不下用户数 10 万时UserCF 的相似度矩阵是 10 万乘 10 万稠密存储需要 80GB 内存直接不可行。解决办法有两个方向数据层面降采样只保留活跃度超过阈值的用户和图书。存储层面用 scipy.sparse 存相似度矩阵只保存非零值。算法层面改成 ItemCF图书数量通常远小于用户数量。我实测过一个 50 万评分的数据集用户 20 万、图书 5 万UserCF 的稠密相似度矩阵无法计算换成 ItemCF 后相似度矩阵只有 5 万乘 5 万用稀疏矩阵后内存占用不到 3GB问题直接解决。6.3 预测评分全都在平均值附近没有区分度出现这个现象通常是因为没有做中心化或者邻居相似度太均匀。有些人给分普遍偏高有些人偏低直接加权平均会把所有预测拉向同一个区间。解决办法是在预测公式里采用“用户平均分 邻居评分偏差加权平均”的形式[ \hat{r}{u,i} \bar{r}u \frac{\sum{v \in N} sim(u,v) \cdot (r{v,i} - \bar{r}v)}{\sum{v \in N} |sim(u,v)|} ]手动实现时把邻居评分先减掉邻居平均分再用相似度加权最后加回当前用户平均分。做完这一步预测值的方差会明显变大推荐列表的个性化程度也会好看很多。6.4 推荐结果总是被少数热门书霸占热门偏差是协同过滤最常见的“职业病”。解决思路通常是引入“惩罚因子”相似度计算时降低热门物品的权重比如对每本书的评分次数取 log 倒数。在最终推荐列表里做一次流行度去偏把全站排名过热的书过滤掉。使用 ItemCF 时对相似项做归一化让相似度的分布更均衡。如果项目里明确要求推荐一定要有一点点“意料之外”的书这类去偏操作就非常关键否则用户会说推荐结果“全是我早就知道的书”。6.5 常见问题速查表问题现象可能原因处理建议相似度矩阵全为 0用户之间没有共同评分项目降低 min_support适当提高评分数据规模所有预测结果一样评分未中心化或 k 值过大使用皮尔逊相似度缩小 k新用户无推荐无历史行为回退热门榜或做基于内容的兜底内存不足稠密相似度矩阵过大改用稀疏矩阵或换 ItemCF推荐全是热门书热门偏差评分人数加权热门惩罚评估 RMSE 很高数据过于稀疏或评分噪声大过滤低活跃用户调高 min_support最后再说一个个人经验别在算法里堆太多炫技的东西。我做这个项目时一开始总想着用矩阵分解、加时间衰减、再上 Word2vec 做图书 embedding结果调参成本极高效果还不稳定。后来把 UserCF 和 ItemCF 都实现干净再加上热门兜底、评分中心化、TopN 去重线上反馈反而好得多。推荐系统的核心从来都是数据质量和对业务场景的理解算法在真正落地时常常只占一小部分。本文还有配套的精品资源点击获取