恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
协同过滤图书推荐系统:原理、代码与评估实现
首页
资讯中心
/
协同过滤图书推荐系统:原理、代码与评估实现
协同过滤图书推荐系统:原理、代码与评估实现
发布时间:2026/10/18 12:52:00
简介这是一个基于Python实现的协同过滤图书推荐系统完整项目面向需要完成毕业设计的高校学生以及从事图书推荐相关研究或开发的读者核心解决图书场景下的个性化推荐问题。压缩包内共68个文件主要包含Python算法与后端逻辑源码、前端交互页面HTML/CSS/JS、论文文档、数据库建表脚本以及多张系统运行截图整体仅3.02MB便于快速下载与部署。配套论文从课题背景、推荐系统发展、协同过滤算法原理到开发环境、系统设计、功能模块和测试方法均有详细论述源码则实现用户评分矩阵、物品相似度矩阵及推荐计算逻辑并配合前端完成图书列表、评分、推荐列表和管理等交互模块。系统截图覆盖图书详情、历史评分、管理页面等关键界面可直观呈现界面布局与核心操作流程。目前已有156人学习下载是一份兼顾理论体系与代码实现、适合毕业设计或推荐系统入门的完整参考资料。1. 一个压缩包背后协同过滤图书推荐系统到底能拿来做什么打开这个压缩包里面通常是三类东西能直接跑的 Python 代码、一篇说明设计思路的论文、一组运行截图。它面向的不是研究前沿问题而是把协同过滤在图书数据上完整落地一遍让有行为记录的用户能拿到一组“猜你想读”的结果。这套东西最适合两类人一类是准备交课程设计或毕业设计需要从原理到代码都能讲清楚另一类是刚接触推荐系统想找个能把 UserCF、ItemCF、相似度计算和评估指标串起来的练手项目。它的价值不在于算法多新而在于把黑匣子打开从评分矩阵到 Top-N 推荐每一步都能复现。2. 选用户还是选物品协同过滤的两种路线和相似度计算2.1 为什么图书推荐偏偏爱用协同过滤图书不同于短视频没有那么多即时标签可供打点。你很难仅凭书名和简介判断两本书是否“相似”但你可以从一群人共同读过的书目里看出它们的关联。协同过滤的核心假设很简单如果用户 A 和用户 B 在历史上读过的书重叠度高那 A 正在读但 B 没读的书很可能也是 B 想读的。图书数据非常适合这个假设。用户买书、借书、评分的行为密度虽然不如点击流但足够形成稳定的偏好信号。一本书被两个相似用户读过的次数越多它作为候选推荐的可信度就越高。相比之下如果换成新闻推荐协同过滤会受制于热点漂移和冷启动但如果换成图书这类长尾商品用户行为反而比内容特征更可靠。从这个角度看压缩包里那套源码真正要解决的问题不是爬数据也不是训练深度学习模型而是把一张稀疏评分表变成可排序的推荐结果。评价它好不好用就看推荐列表里有多少书是用户确实想读、但自己没找到的。2.2 基于用户与基于物品的适用边界协同过滤有两条路线代码里的分支往往就在这里分叉。基于用户的协同过滤UserCF先找“和我口味一致的人”再把他们读过的书推给我。适合用户规模相对小、社交属性强的场景比如小组书单。缺点是用户一旦增长用户间相似度矩阵计算开销很大而且对新用户不友好。基于物品的协同过滤ItemCF先找“和我读过的书相似的书”再按相似度排序。这里的相似不是内容相似而是“被同一批用户共同购买或评分”的次数多。图书这类静态商品非常适合 ItemCF书不会频繁变化相似关系可以离线算好线上直接查表而且推荐理由好解释“因为你看过 X所以推荐相似的 Y”。我做这类系统时默认先做 ItemCF。原因不是它更复杂而是它更容易出稳定结果。图书的品类不会一天一变物品相似度矩阵可以预先算好用户相似度矩阵每次新增用户都要重算成本随用户数平方增长。除非你的场景是“老用户带新用户”的社区推荐否则优先选 ItemCF。2.3 三种相似度的公式与直觉无论哪条路线核心都是相似度计算。最常用的是三种余弦相似度、皮尔逊相关系数、杰卡德相似系数。余弦相似度把每个用户的评分看成向量计算夹角余弦。它只看方向不受用户打分尺度的影响。皮尔逊相关系数在余弦基础上减去了各自均值相当于做了中心化能纠正“有人习惯打 4 分有人习惯打 5 分”的偏差。杰卡德相似系数只看两个向量共有的非零元素个数适合没有评分只有“是否读过”的场景。下面这段代码实现了带均值中心化的皮尔逊相关系数是压缩包源码里最核心的零件# 计算两个用户/物品向量的皮尔逊相关系数 def pearson(vec1, vec2): # 找出两个向量都有评分的索引避免把缺失值当 0 common [i for i in range(len(vec1)) if vec1[i] ! 0 and vec2[i] ! 0] if len(common) 2: # 共同评分数太少相似度没有统计意义 return 0.0 s1 [vec1[i] for i in common] s2 [vec2[i] for i in common] mean1 sum(s1) / len(s1) mean2 sum(s2) / len(s2) num sum((s1[i] - mean1) * (s2[i] - mean2) for i in range(len(s1))) den (sum((x - mean1) ** 2 for x in s1) * sum((x - mean2) ** 2 for x in s2)) ** 0.5 if den 0: # 一方评分完全相同无法区分 return 0.0 return num / den这段代码的逻辑是先筛出两个向量共同有评分的维度共同项太少直接返回 0对双方做中心化让评分尺度差异不影响结果再把协方差除以标准差乘积得到 [-1, 1] 的相关系数。需要注意函数里的 0 表示“没有评分”不要把值本身为零的真实评分混进来。实际数据中评分最小是 1所以用 0 占位是安全的。如果你直接拿稀疏矩阵去算余弦相似度缺失值会当作用户打了 0 分结果会被大量虚假的共同不评分拉高这是最常见的坑。2.4 相似度计算遇到“只有一个共同评分”的边界不少读者会在这一行代码上翻车if len(common) 2。为什么要严格小于 2 就返回 0因为当两个向量只有一个共同维度时皮尔逊相关系数的分母无法计算标准差只有 1 个点即使能算结果也会是 1 或 -1。这样的“绝对相似”没有意义只会让推荐列表被一对只碰巧共同读过一本书的书霸占。我在模拟项目X里就见过这样的翻车调低阈值后相似度矩阵里出现大量相似度为 1 的书对一看数据全都是两本书各自只有一条评分且评分的刚好是同一个用户。解决方法是把len(common) 2调成len(common) 3或更高用共同评分数做置信度门槛。不同数据集的合理门槛不一样经验上图书评分数据集至少要求 3 个共同用户否则相似度矩阵的噪声会直接淹没信号。3. 手写一个图书推荐核心从评分矩阵到 Top-N 推荐3.1 数据清洗把原始评分表变成用户-物品矩阵大多数图书评分数据的原始格式是 CSV三列用户 ID、书籍 ID、评分。别指望数据干净同一用户对同一本书可能有多条记录用户 ID 可能是数字也可能是字符串书籍 ID 里可能带空格。我习惯先把数据读进来去重再转换成字典结构。import csv from collections import defaultdict # 读取原始CSV输出 用户-书籍-评分 的三层字典 def load_ratings(filepath): data defaultdict(dict) with open(filepath, newline, encodingutf-8) as f: reader csv.reader(f) header next(reader, None) # 跳过表头 for row in reader: if len(row) 3: continue user, book, rating row[0].strip(), row[1].strip(), row[2].strip() try: rating float(rating) except ValueError: continue # 评分无法转成数字直接丢弃 data[user][book] rating # 同一用户同一本书后读的覆盖 return data这里有几个隐藏参数要说明header next(reader, None)是为了兼容有没有表头的文件row[0].strip()去掉首尾空格否则同一个书籍 ID 可能因为空格被当成两个不同物品评分转换失败的行直接丢弃比报错停下来更符合清洗逻辑。为什么要用defaultdict(dict)因为字典的键是用户值是用户到书籍评分的映射这样构建三层结构时不需要先判断键是否存在。下一步是给所有用户和书籍编号生成两个映射表方便转成矩阵或向量。先收集所有出现过的书去重后赋予自增编号def build_index(data): users {u: i for i, u in enumerate(data.keys())} all_books set() for user_ratings in data.values(): all_books.update(user_ratings.keys()) books {b: i for i, b in enumerate(all_books)} return users, books这个版本的逻辑很清晰users把用户 ID 映射到整数索引books把书籍 ID 映射到整数索引。两个字典的作用是在字符串 ID 和矩阵位置之间建立桥梁。实际使用时如果内存紧张可以不生成稠密矩阵直接用字典加索引但生成索引后后续调用pearson时能够更快地查位置。3.2 用户相似度与预测评分核心代码实现有了索引下一步是把字典转成固定大小的向量然后实施 ItemCF。我以 ItemCF 为例因为它在图书场景里表现更稳定。先为每本书构建“被哪些用户评过分”的倒排表再计算书籍之间的相似度矩阵。# 构建 书籍ID - 用户评分列表 的倒排表 def build_inverted(data, books): book_users {} for user, ratings in data.items(): for book, score in ratings.items(): book_users.setdefault(book, {})[user] score return book_users # 计算书籍两两相似度只保存相似度大于阈值的对 def calc_item_sim(book_users, threshold0.4): from collections import defaultdict sims defaultdict(dict) books list(book_users.keys()) for i in range(len(books)): for j in range(i 1, len(books)): b1, b2 books[i], books[j] common set(book_users[b1].keys()) set(book_users[b2].keys()) if len(common) 3: # 共同评分用户太少相似度不可靠 continue vec1 [book_users[b1][u] for u in common] vec2 [book_users[b2][u] for u in common] sim pearson(vec1, vec2) if sim threshold: # 只保留有意义的相似关系 sims[b1][b2] sim sims[b2][b1] sim return sims倒排表的作用是把“用户评分”转成“物品被哪些用户评过”这样计算物品相似度时不需要遍历全量用户矩阵。threshold0.4是个经验值阈值太低会让相似关系充满噪声太高会让图谱稀疏推荐的召回率下降。我一般会先用 0.4 跑一遍看生成的相似关系对数再根据数据稀疏程度调整。len(common) 3的限制也很关键只有一两个共同评分用户算出的相关系数几乎都是 ±1那不是信号是巧合。如果你选择 UserCF代码结构类似但要把倒排表转成用户之间的相似度。区别在于 ItemCF 计算时要取用户集合的交集UserCF 取书籍集合的交集。两种方法共用同一个pearson函数真正的切换点只是矩阵的转置。3.3 生成推荐列表加权重排与去重相似度矩阵算好后给某个用户推荐书就简单了找出用户评分最高的几本书再找出和这些书相似且用户没读过的书按相似度加权累加排序。def recommend(user, ratings, sims, top_k5, n_reco10): user_books ratings.get(user, {}) scores defaultdict(float) # 遍历用户评分过的书作为兴趣锚点 for book, rate in user_books.items(): # 遍历与锚点书相似的其他书 for other, sim in sims.get(book, {}).items(): if other in user_books: continue # 只看没读过的书 scores[other] sim * rate # 用评分加权相似度 # 按加权分排序返回前n_reco个书籍ID sorted_books sorted(scores.items(), keylambda x: x[1], reverseTrue) return [book for book, _ in sorted_books[:n_reco]]这里的加权方式是把“用户对锚点书的评分”乘以“锚点书与候选书的相似度”所有锚点贡献的分数累加。评分高的书贡献权重更大这符合直觉你很喜欢的一本书它的相似书应该在推荐列表里排名更靠前。top_k5参数表示只用用户评分最高的 5 本书作为锚点这样既能控制计算量又能避免长尾书把推荐方向带偏。如果用户读过的书太少这个函数会直接返回空列表这是冷启动问题后面第 5 章专门说。n_reco10是输出数量。实际调参时top_k 太大会让推荐结果趋同太小则忽略用户中低分偏好n_reco 没有硬性规定演示环境设 10 够用。另一个容易被忽略的点scores是加权累加而不是平均值所以读过多本书的用户天然会积累更高分数这个偏差在按时间排序的推荐里问题不大但对比不同用户结果时要注意。4. 配合源码的论文怎么写评估指标与实验设计4.1 数据集划分与评估指标选择拿到能跑的推荐代码之后压缩包里那份论文最需要补上的内容就是评估。没有评估就要论文等于把推荐系统当成了黑匣子。常见的做法是先把数据集按时间顺序或随机分成训练集和测试集比例为 80/20。对图书推荐来说按时间切分更贴近真实场景用用户前 80% 的行为训练预测后 20% 的行为。按时间切分可以用一行排序完成但要注意用户可能有多条记录。假设原始数据是用户行为日志列表每一条包含书籍 ID、评分、时间戳可以这样切分# 按时间切分用户行为数据 def split_by_time(records, time_keytimestamp, ratio0.8): records.sort(keylambda x: x[time_key]) split_point int(len(records) * ratio) return records[:split_point], records[split_point:]这个函数的输入是一个用户的行为记录列表输出是训练列表和测试列表。按用户切分比整体切分更好能保证训练集和测试集在用户维度上不冲突。如果你的数据是用户到书籍评分的字典结构先把它还原成带时间戳的记录列表再切分否则时间信息会丢失。评估指标分两组。评分预测类任务用 RMSE 或 MAE直接比较预测评分和真实评分的误差。Top-N 推荐任务用 PrecisionN、RecallN回答“推荐列表里有多少是用户真正读过的”。下面这个函数在测试集上同时统计 RMSE 和召回率# 用训练集计算相似度在测试集上评估RMSE和召回率 def evaluate(train, test, sims, top_k5, n_reco10): total_se, count 0.0, 0 hit, total_positive 0, 0 for user, test_items in test.items(): recs recommend(user, train, sims, top_k, n_reco) rec_set set(recs) # 统计预测评分误差 # 预测函数 predict_rating 需要单独实现 for book, real_rate in test_items.items(): pred predict_rating(user, book, train, sims) total_se (pred - real_rate) ** 2 count 1 # 统计Top-N命中数 hit len(rec_set set(test_items.keys())) total_positive len(test_items) rmse (total_se / count) ** 0.5 recall hit / total_positive if total_positive else 0 return rmse, recallpredict_rating需要根据用户对其他相似物品的评分加权预测目标物品评分它和推荐列表的区别在于推荐不关心具体分数只关心排序而评分预测需要输出一个 1-5 的数。如果压缩包源码里没有这个函数你可以用平均评分兜底。评估时要注意RMSE 对评分尺度很敏感必须保证预测值也落在训练集的评分范围内否则会出现“预测 4.8实际 2.0”的夸张误差。total_positive是测试集里用户有行为的物品数hit是推荐列表和实际行为求交集的命中数。注意这里算的是召回率精确率需要另一个分母也就是推荐列表的总数乘以测试用户数。两个指标同时报才能看出推荐是“宁可错杀不可放过”还是“精准但召回少”。4.2 离线实验的几张结果表论文里最有力的说服工具是不同方法在同一数据集上的结果表。不要只给一个孤立数字要给出对比。下面这张表可以作为模板列里写相似度计算方式和推荐算法方法相似度精确率召回率RMSEUserCF皮尔逊0.02410.13620.9823ItemCF皮尔逊0.03850.17860.9137ItemCF余弦0.03120.15400.9431表格说明不用多但要交代清楚数据的稀疏程度多少用户、多少本书、评分密度是多少。如果密度低于 1%你可以预期精确率普遍低于 5%这很正常。论文里把这张表贴上之后紧接着写一句话解释为什么 ItemCF 比 UserCF 好图书品类稳定、相似关系集中ItemCF 的相似度矩阵不会因为新用户加入而失效。4.3 论文里怎么描述系统架构和模块压缩包里的论文如果只有算法推导是不够的还要有系统实现章节。建议画一张模块图论文里可以用截图替代分成三层数据层、推荐引擎层、展示层然后在每个模块下面写对应代码位置。不要只贴代码要描述数据流向原始 CSV 进入数据清洗模块得到用户-物品评分字典推荐引擎层分出离线相似度计算和在线推荐两个独立过程展示层用命令行或简单前端输出结果。写论文有一个容易翻车的地方只写算法原理却不解释参数怎么定的。比如 top_k 为什么是 5阈值为什么是 0.4。你需要补一段“参数敏感性”的说明哪怕只是跑一组 K3/5/10 的结果选一个折中点。这样评审才能看出你不是乱设的。论文的主体结构不建议按源码行文而是按思想组织。先用第一章讲背景和问题说明图书推荐为什么有价值第二章讲协同过滤原理和相似度选型第三章讲系统设计和数据流第四章讲实验设置和结果最后一章写不足与展望。压缩包里的截图最好放在系统设计和实验结果两章截图要能对得上代码逻辑比如展示“用户输入用户 ID 后返回了哪些推荐书籍”而不是只贴一张终端输出。5. 踩坑与排查协同过滤实现里那些翻车现场5.1 相似度矩阵全是 0推荐列表一直空现象跑完训练打印相似度矩阵发现绝大多数对都是 0推荐结果要么为空要么只有两三本书。原因基本是数据里每本书只有零星几个评分共同评分用户数永远小于阈值。我在模拟项目X里就犯过这个错拿一个只有 3000 条评分的数据集去算物品相似度阈值设成 3结果矩阵稀疏到没法用。解决先打印统计数据每本书平均有多少人评过分、每个用户平均评了几本书。如果每本书平均评分人数不到 10就把common的最低共同人数从 3 降到 2同时把相似度阈值从 0.4 降到 0.2。另一个做法是把评分矩阵转置换用 UserCF用户数量少时 UserCF 的相似度反而更稠密。这条经验说明一个道理参数不是凭感觉设的是看数据密度后决定的。# 打印数据密度的诊断代码 def diagnose(data): total_ratings sum(len(v) for v in data.values()) n_users len(data) all_books set() for v in data.values(): all_books.update(v.keys()) n_books len(all_books) print(f用户数 {n_users}, 图书数 {n_books}, 评分总数 {total_ratings}) print(f密度 {total_ratings / (n_users * n_books) * 100:.3f}%)这段代码应该在训练任何相似度之前先跑一遍。密度低于 1% 时你该做的是合并稀疏行为而不是调模型。可以在过滤条件里要求图书至少有 5 人评分、用户至少评过 2 本剪掉垃圾数据后再算。5.2 冷启动用户进来没推荐现象新用户或只有一条评分记录的用户调用推荐函数返回空列表。原因很简单代码里一旦把用户读过的书全部跳过就没有候选了。我在演示时遇到过评审当场注册一个新账号然后界面上只显示“暂无推荐”的尴尬情况。解决至少给两个兜底方案。第一个是热门兜底统计训练集里平均评分最高的热门图书推荐给没有行为记录的用户。第二个是评分兜底用户只给了一本书评分时直接用这本书的相似书作为推荐不再做加权累加。这两种方案在代码里也就是加一条 if 判断但能让系统在任何情况下都有输出论文里也可以把这个设计写成“冷启动处理模块”。# 冷启动兜底缺少评分数据时返回热门书 def recommend_with_fallback(user, ratings, sims, popularity, top_k5, n_reco10): if user not in ratings or len(ratings[user]) 0: return popularity[:n_reco] # 热门兜底 if len(ratings[user]) 1: only_book list(ratings[user].keys())[0] cand [b for b, s in sorted(sims.get(only_book, {}).items(), keylambda x: x[1], reverseTrue)] return cand[:n_reco] # 单书相似兜底 return recommend(user, ratings, sims, top_k, n_reco)popularity是预先按平均评分和评分人数加权算好的热门书列表。兜底逻辑要写在入口处避免每个调用点都重复判断。这个函数在论文里可以作为“冷启动模块”的核心代码。5.3 热门书霸榜推荐结果千篇一律现象推荐列表永远是那几本大众书用户的历史偏好好像对结果影响不大。原因是相似度计算没有做热门惩罚一本被 1000 人读过的书和另一本被 1000 人读过的书共同用户自然很多相似度虚高。这是协同过滤里最典型的长尾问题书越热门越容易被推。解决对相似度做逆热门加权。常见做法是引入一个惩罚项比如计算相似度时把共同用户数除以双方的热门度对数乘积。修改calc_item_sim中的 sim 计算# 使用逆热度惩罚的相似度权重随共同用户数增长但以对数衰减 import math def sim_with_penalty(b1, b2, common_count, sim, hotness_b1, hotness_b2): penalty 1.0 / math.log(2 common_count) # 共同用户越多惩罚越大 # 对热门物品额外打折避免大众书互相抱团 hot_penalty 1.0 / math.log(2 (hotness_b1 hotness_b2) / 2) return sim * penalty * hot_penalty实际调参时hotness_b1可以用这本书的总评分次数common_count是共同评分用户数。这段代码的直觉是两本书共同出现的次数多不一定是真相似也可能只是大家都会看热门书所以要用对数项把它拉低。参数 2 是对数里的平滑项防止热门度 1 的时候除数为零。经过这个惩罚推荐列表才终于能出现冷门但贴合用户兴趣的书。5.4 评分时间顺序没处理评估结果虚高现象随机划分训练集和测试集时RMSE 看起来很低但上线后推荐效果明显变差。原因是评分数据中很多用户先低分后高分或者反过来随机划分把同一用户的时序信息打乱了。推荐系统本质是预测未来行为用随机划分等于让模型“提前看答案”。解决按时间切分。如果你的原始 CSV 里有评分时间戳列先按时间升序排序再取用户行为的前 80% 作为训练集。如果没有时间戳至少按用户 ID 做分层抽样保证同一个用户的评分不会被完全切开。这个改动会让 RMSE 数值变差但更接近真实效果。论文里要明确写清楚划分方式否则评审可以合理怀疑你的结果有水分。6. 从能跑到能讲推荐系统演示的三个进阶技巧如果只是把压缩包里的代码跑通演示的时候往往 5 分钟就讲完了。我习惯再做三件事让推荐系统看起来像产品而不是脚本。第一把推荐结果加一行“为什么推荐”对每本书记录相似度最高的来源书输出时不只给书 ID也给“因为你看过《某书》所以推荐《某书》”。这不会增加计算量只需要在 recommend 里额外保存来源。演示时这句解释远比一串评分数字有说服力。第二加一个时间衰减权重用户几个月前打的 5 分和昨天打的 5 分权重不应当一样。在预测评分时乘以 exp(-天数/365)能让推荐更贴近当前兴趣。这个改动很小但能挡住“用户已经不喜欢这本书还按旧权重推”的尴尬。第三给系统留一个手动切换 UserCF/ItemCF 的开关方便在演示时对比效果。哪怕实现两个函数也让用户自己选推荐模式评审会觉得你是真的懂两种算法的区别而不是只会抄代码。这三件事做完一份源码加论文的推荐系统就能从“能跑”升级成“能讲”。我现在的习惯是先确保任何输入用户都不会得到空推荐再谈算法优化否则冷启动那条坑会在你想不到的时候翻车。希望帮到你。本文还有配套的精品资源点击获取