做Python实战项目做到第六个,我决定挑战一个完整度更高的东西:图书馆图书推荐系统。起因很现实——常去的社区图书馆,检索系统只能回答"这本书在不在架",完全没有"猜你喜欢"这种能力。新书区常年堆灰,热门书却永远借不到,馆员推荐基本靠个人经验,读者靠缘分找书。我想用Python把"数据处理→算法选型→推荐实现→效果评估"这条链路完整跑一遍,看看到底能做到什么程度。
这篇实战日志的核心内容包括:借阅数据怎么清洗成算法能吃的格式、为什么选UserCF而不是上来就搞深度学习、协同过滤的完整手写实现、冷启动和数据稀疏问题怎么兜底,以及最后怎么用离线指标和人工抽检验证推荐效果。适合正在学Python、想拿一个完整数据项目练手的朋友,也适合做图书管理系统的开发者参考。
1. 项目背景:图书馆的"猜你喜欢"和电商根本不是一回事
1.1 借阅场景的三个独特痛点
先说我观察到的现象。图书馆和电商看起来都是"给用户推东西",但本质差异非常大。电商里用户会留下评分、收藏、加购这些信号,转化率能直接验证推荐效果;图书馆这边,读者借书留下的只有一条借阅流水,没有评分、没有点赞,甚至很多人还书之后什么反馈都没有。数据稀疏程度比电商严重一大截。
具体到做推荐这件事,有三个绕不开的痛点。第一,冷启动问题突出:每年开学都会涌进一批新读者,他们的借阅记录是零,算法完全无从下手。第二,重复借阅极少见:电商用户会反复购买同一件商品,但图书馆读者几乎不会重复借同一本书,这意味着传统"找相似用户买过的东西"思路要调整。第三,图书长尾属性极强:热门书就那么几十本,剩下几万本可能一年都借不出去一次,如果推荐系统一直把热门书推给所有人,那和直接摆个畅销书架有什么区别。
1.2 我给这个项目划定的边界
考虑到是个人实战项目,我没有直接对接真实图书馆数据库,而是构造了一套模拟借阅数据。三张表:用户表(用户ID、读者类型、年龄段),图书表(图书ID、书名、作者、分类、出版年份),借阅流水表(用户ID、图书ID、借出日期、归还日期)。规模上模拟了500个用户、3000本图书、约6万条借阅记录,矩阵稀疏度大约4%——比真实图书馆稍微稀疏一点,但恰好适合检验算法的鲁棒性。
范围上我也做了克制:不做实时推荐,不做Web展示,专注离线推荐链路。先把推荐逻辑本身做扎实,再考虑怎么包装成服务。整个项目前后花了两周,大头不在写代码,而在调数据和调参数。
提示:如果用真实图书馆数据,图书ID经常有一册多卷、同书异版的问题,清洗时必须按书目ID聚合,不能按条形码拆开算,否则同一本书会被拆成几十个"物品",推荐列表会变得非常碎。
2. 数据处理:借阅流水怎么变成算法能吃的格式
2.1 先摸清数据底细再做算法
拿到三张表后,第一步不是写推荐算法,而是用pandas先把数据摸一遍。我统计了几件事:每本书的被借次数分布、每个用户的借书数量分布、借阅流水的时间跨度。这些统计直接决定后面的算法策略。
结果印证了我的猜测:排名前50的热门书占了总借阅量的将近40%,超过一半的图书在整个统计周期内没有被借过。这是典型的长尾分布,意味着如果算法不做干预,推荐列表必然被头部图书垄断。我还发现用户借阅量的方差很大,最活跃的用户半年借了60多本,大量用户只借过一两本,这为后面做相似度计算埋了个大坑。
2.2 三个必踩的清洗坑
数据清洗是整个项目最琐碎但也最要命的部分,我整理三个真实踩过的坑。
第一个坑是多卷书。数据里有几套书分多册入库,条形码不同但书目ID相同,如果直接用条形码建矩阵,同一本书会被算成三四个物品,借阅记录被拆散。我的处理是按书目ID去重,把同一书目下的借阅次数累加。
第二个坑是还书日期缺失。大约2%的记录没有归还日期,一开始直接丢弃,后来发现这些记录集中在某个时间段,丢完之后那段时间的用户行为全部消失,部分用户的借阅历史被拦腰截断。最后的处理方式是:缺失归还日期但借出日期完整的记录,保留参与协同过滤计算,不参与时间相关统计。
第三个坑是异常用户。我扫出几个借阅量超过正常水平十倍的用户,起初以为是脏数据,细查发现是"代借"行为——有人拿朋友的卡批量借书。这类用户在相似度计算里会把某些冷门书无限放大,需要在建矩阵前对借阅次数做上下限截断,把超过阈值的天数按阈值处理。
2.3 构建用户-物品隐式反馈矩阵
清洗完成后,我把借阅流水转成稀疏矩阵:行是用户,列是图书,单元格填"是否借过",借过为1,没借过为0。没有评分,这是图书馆场景的天然约束。
关键在于理解隐式反馈的语义:0不代表"不喜欢",只代表"没借过"。所以后面评估不能走RMSE这类回归指标,只能看排序类指标。矩阵结构我用scipy的稀疏矩阵存,500×3000全量下来150万个数字,二维列表其实扛得住,但真实图书馆是几十万用户×几百万图书,不用稀疏结构内存直接爆。这个习惯建议一开始就养成。
3. 算法选型:为什么先用协同过滤,而不是写一堆规则或上深度学习
3.1 推荐算法家族里的三选一
主流推荐方案大致三类:基于内容的推荐、协同过滤、深度学习模型。基于内容需要给每本书抽取丰富特征(关键词、主题、文风),图书元数据里只有作者和分类,特征太薄,做出来的推荐会很粗糙。深度学习需要大量行为数据,图书馆这种稀疏场景很容易过拟合,而且调参周期长,不适合作为第一版。协同过滤只需要用户的历史行为,不需要理解内容本身,和"没有评分、数据量不大"的场景最匹配。
协同过滤内部又分两派。UserCF(用户协同过滤)的逻辑是"和你兴趣相似的人借了什么,你大概率也感兴趣";ItemCF(物品协同过滤)的逻辑是"你借过的书的相似书,你也会想借"。两条假设都成立,但适用阶段不同。
3.2 我最终用UserCF做主算法,但ItemCF也没丢
选UserCF做主推算法有三个理由。第一,图书馆读者的兴趣变化相对缓慢,以借阅行为聚类的用户群体比较稳定。第二,新读者占比高的情况下,UserCF能借助老读者的行为给新读者做扩展推荐。第三,从实现成本看,用户量500这个规模,用户相似度矩阵计算完全可控。
但说实话,ItemCF在图书馆场景的价值同样很高——尤其是"借过这本书的人还借了哪些书"这种关联推荐。图书馆用户找书通常带着明确目的,他看到一本目标书旁边写着"借过这本书的人也借了某某",这个信息比泛泛的"猜你喜欢"更有用。所以我的最终方案是UserCF生成主推荐列表,ItemCF做结果扩充。
3.3 相似度公式:余弦还是杰卡德
计算用户相似度最常用的是余弦相似度,但图书馆场景有个问题:大部分用户借阅量很少,两个用户之间共同借过的书可能为零,余弦值全是0,矩阵稀碎。杰卡德相似度(交集除以并集)在稀疏场景下更稳,因为它看的是共同行为占各自行为总和的比重,对绝对数量不敏感。
我用一个小测试集验证过:用户A借了5本,用户B借了其中3本加2本别的,余弦和杰卡德给出的排序结论一致;但当一个用户只借了1本且恰好和另一个用户重叠时,余弦会给出异常高的相似度,杰卡德则会把这种"只有一次共同行为"的情况压下去。在行为数据极稀疏的借阅场景,我更信任杰卡德。
提示:很多教程直接套余弦相似度,但在借阅记录这种二元稀疏数据上容易算出虚高的相似度。建议先用杰卡德跑一版,对比离线指标后再决定。
4. UserCF从零实现:倒排表、相似度矩阵与推荐排序
4.1 用倒排表避免O(n²)的暴力遍历
拿到稀疏矩阵后,最容易想到的做法是直接双层循环计算所有用户对的相似度,复杂度O(n²)。500个用户没问题,5000个用户就开始吃力了。更聪明的做法是倒排表:以图书为key,记录借过这本书的用户ID集合。之后只对"共同借过至少一本书"的用户对计算相似度,没共同行为的用户对相似度本来就是0,算了也是浪费时间。
from collections import defaultdict def build_inverted_index(records): """ records: list of (user_id, book_id) 返回 {book_id: set(user_ids)} """ inverted = defaultdict(set) for user_id, book_id in records: inverted[book_id].add(user_id) return inverted def compute_user_similarity(records): inverted = build_inverted_index(records) # 统计每个用户借过的书数量,用于杰卡德分母 user_book_count = defaultdict(int) # 统计用户对的共同借阅次数 co_occurrence = defaultdict(int) for book_id, user_set in inverted.items(): for u in user_set: user_book_count[u] += 1 user_list = list(user_set) for i in range(len(user_list)): for j in range(i + 1, len(user_list)): u1, u2 = user_list[i], user_list[j] # 只记录上三角,省一半空间 key = (u1, u2) if u1 < u2 else (u2, u1) co_occurrence[key] += 1 similarity = {} for (u1, u2), common in co_occurrence.items(): denom = user_book_count[u1] + user_book_count[u2] - common if denom > 0: similarity[(u1, u2)] = common / denom # 杰卡德相似度 return similarity调试这段代码时踩了一个小坑:如果用户ID有的是字符串、有的是整数,u1 < u2比较时直接抛类型错误。建议清洗阶段把所有ID统一转成字符串。
4.2 推荐列表怎么生成:候选集、邻居权重、K值
有了相似度矩阵,对目标用户u:找出相似度最高的K个邻居(我用的K=20),把这K个邻居借过的书汇总成候选集,剔除u已经借过的书,然后按加权分数排序。
加权分数的公式是:候选书的得分 = 借过这本书的所有邻居对u的相似度之和。两个细节需要注意。第一,只保留相似度大于阈值(我设0.15)的邻居,避免只有一次共同借阅行为的弱邻居污染结果。第二,候选书被越多高相似度邻居借过,得分自然越高,这符合"越多相似的人借过越值得推荐"的逻辑。
关于K值,我对比了10、20、50三组。K太小,推荐结果受单个邻居影响太大,容易偏科;K太大,低相似度邻居的噪声被放大,列表变得平庸。模拟数据上K=20效果最好,但这个值不通用,真实场景务必自己调。
4.3 第一次跑通后暴露的两个问题
首版推荐列表跑出来后,我整个人都不太满意。两个问题尤其扎眼。
第一,热门书霸榜。大众读物出现在几乎所有用户的推荐前列,推荐系统直接沦为了畅销榜。解决办法是热门书降权:最终得分乘一个惩罚系数,把图书按被借次数排名映射到(0.4, 1.0)区间,热门书分数压低,冷门书维持原分。这个方案温和,既不彻底压死头部,又不至于让推荐列表变得猎奇。
第二,相似邻居高度重叠带来的推荐重复。K个邻居里如果有几个人兴趣几乎一样(现实中就是一个兴趣小组的成员),他们推荐的书高度雷同。我的第一版处理很朴素:同一位作者在推荐列表里最多出现2本书,系列书按卷册顺序重排。更精细的MMR(最大边际相关)多样性重排留到了后续优化清单里。
5. 直面三个老大难:冷启动、稀疏矩阵、兴趣漂移
5.1 新用户和新书的两种兜底思路
新用户没有任何借阅行为,相似度矩阵里用户对应的行全是0,推荐无从谈起。我的处理是分层兜底:完全没有借阅记录的用户,推荐当前热门书中他未读过的,标注为"人气好书";有1-3条借阅记录的用户,先按他借过的书做类别偏好统计,推荐同类目下借阅热度靠前的书,等行为数据积累到一定程度再切回UserCF。
新书冷启动更麻烦,因为协同过滤只能依据行为数据,新书天生没有行为。我给新书上架头30天设了"观察期":不参与协同过滤推荐,只在"新书上架"栏目展示。30天后一旦产生借阅行为,就正常进入推荐候选池。这样能避免新书因推荐曝光获得虚高借阅量、再反过来抬高推荐权重的马太效应。
5.2 稀疏度4%下,相似度失真怎么缓解
4%的稀疏度意味着平均每个用户只和不到120本书发生关系,而全量图书有3000本。用户之间共同借阅的书极少,"共同借过1本书"很可能纯粹是巧合。
我的实用招数是:构建倒排表之前,先过滤掉被借次数低于3次的冷门书。原因是一个用户借了某本超级冷门的书,大概率是偶然行为,让这种书参与相似度计算会放大噪声。过滤之后,用户相似度的质量明显提升,离线指标涨了几个百分点,推荐结果的可解释性也更强。
5.3 时间衰减:让算法更尊重"最近"的行为
图书馆读者的兴趣会漂移。上学期狂借计算机书的人,这学期可能转向了心理学。如果相似度计算对全部历史行为一视同仁,过去的兴趣会长期压制当下的兴趣。
我加了一个时间衰减权重:借阅行为按借出日期计算权重,权重 = exp(-(当前日期 - 借出日期)/180天)。也就是说,6个月前的行为权重衰减到约37%,一年前衰减到约13%。半衰期参数可以根据图书馆的借阅周期调整,学术型图书馆借阅周期长可以设得更久,社区图书馆借阅周期短可以缩短。
6. 效果评估:离线指标加人工抽检,谁都不能少
6.1 按时间切分,而不是按随机切分
评估推荐系统很忌讳随机划分训练集和测试集。推荐系统预测的是"用户未来会借什么",不是"用户过去借了什么"。所以我按借出日期排序,前80%的记录做训练集,后20%做测试集。这样评估的是"用过去预测未来"的真实能力,而不是数据泄漏下的虚假繁荣。
6.2 Precision、Recall、Coverage三个核心指标
离线评估指标我选了三个最核心的。Precision@K看推荐列表准不准:推荐列表里有多少本书真的在测试期被用户借了。Recall@K看推荐全不全:用户实际借的书里,有多少被推荐出来了。Coverage看覆盖面:推荐系统一共推荐了多少比例的图书,防止系统只盯头部热门书。
这三个指标互相拉扯。追求Precision,推荐列表会越来越保守,集中在热门书和高置信度书上;追求Coverage,推荐列表向长尾分散,Precision必然下降。我的目标是Precision和Recall保持在合理区间的同时,Coverage尽量高。最终跑出的结果:
| 指标 | 含义 | 我的结果 | 备注 |
|---|---|---|---|
| Precision@10 | 推荐10本中被实际借阅的比例 | 约18% | 受稀疏度限制,低于电商场景 |
| Recall@10 | 测试期借阅书籍被推荐到的比例 | 约21% | 长尾兴趣召回偏弱 |
| Coverage | 推荐列表覆盖图书总数的比例 | 约22% | 热门书降权后有所回升 |
说实话这精度不算高,但在4%稀疏度的模拟数据上,已经能说明算法确实学到了用户兴趣模式,而不是在瞎猜。
6.3 人工抽检:把推荐结果拿给"人"看一遍
离线指标再漂亮,也代替不了逐条看结果。我从测试集随机抽了20个用户,打印他们的借阅历史、推荐列表和推荐理由,一条一条过。这个环节发现的问题比指标的维度丰富得多。
典型问题有三类。其一,推荐理由千篇一律,大量推荐都写着"和你兴趣相似的读者也借了这本书",用户容易产生"算法在糊弄我"的感觉;其二,系列书推荐顺序不对,用户借了第一部,列表把第三部排在前面,第二部反而靠后;其三,同一作者的书被过度推荐,某用户借了一位作者的散文集,推荐列表里该作者的书占了四本。针对这三个问题,我分别做了修正:推荐理由补充"这本书和你借过的某本书同属一个分类";系列书按卷册顺序重排;限制同一作者推荐数量不超过两本。
7. 项目做完后的几点体会
整个项目做下来,最大的感受是:推荐系统好不好用,算法只占一半,另一半在数据质量和场景理解。借阅记录的清洗、稀疏度的应对、冷启动的兜底,每一块花的时间都不比写算法少。
如果后续继续扩展,我觉得有四个方向值得做。一是把ItemCF的关联推荐做成"借了A的人也借了B"的独立栏目,对图书馆用户价值很高;二是引入MMR做多样性重排,解决推荐列表雷同的老问题;三是把续借次数、借阅时长引入隐式反馈,续借过两次的书大概率比按时归还的书更值得推荐;四是等数据量上来之后,尝试用图嵌入算法建模用户-图书二部图,效果可能比传统协同过滤更好。
最后分享一个我养成的习惯:每次调完参,不光看离线指标,一定随机抽10个用户看推荐结果。指标提升不一定是真提升,可能是模型变得更投机;人工过一遍,才能确认推荐系统是在朝正确的方向优化。做推荐,永远要记得指标是手段,用户拿到推荐列表那一刻的感受才是目的。