ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于协同过滤的图书推荐系统:隐式反馈评分矩阵与工程实践

基于协同过滤的图书推荐系统:隐式反馈评分矩阵与工程实践 简介这是一篇西南财经大学学士学位毕业论文主题为基于协同过滤算法的图书推荐系统设计与实现面向计算机科学、数据科学、人工智能等专业的本科生、研究生及对推荐算法感兴趣的科研人员。论文系统梳理了用户-用户与物品-物品两类协同过滤方法的原理与差异并结合图书推荐场景完整呈现数据收集、清洗、特征提取、相似度计算、推荐生成与反馈优化的全流程设计。资源为单个docx文档压缩包约30KB篇幅覆盖引言、相关技术综述、系统设计与实现、实验与评估、系统性能与优化等完整章节可直接作为毕业设计参考模板使用。文中给出总体分层架构、数据处理与推荐算法模块的详细说明并围绕推荐准确率、覆盖率、多样性等指标开展实验与结果分析同时讨论协同过滤的优缺点及矩阵分解、深度学习等延伸方向。目前已有546人学习适合需要快速搭建研究框架、理解算法落地细节并撰写论文的读者参考。1. 图书推荐系统的第一道坎为什么借阅记录比评分更能说明问题图书馆或图书电商的推荐模块上线前最容易被忽略的一点是用户几乎从不打分。1 到 5 星的评分表在后台翻出来往往是空的能拿到的全是借阅、续借、收藏、加入书架、详情页停留这类行为流水。协同过滤算法本身依赖「用户-物品-偏好」三元组直接把点击当成 5 分塞进去推荐结果会被蹭到的热门书带偏完全不处理矩阵里连可算的共现都凑不出来。这就是「基于协同过滤算法的图书推荐系统设计与实现」真正要面对的工程问题数据是隐式反馈、物品是长尾分布、用户是低频访问。把行为折算成偏好强度构造成可计算的评分矩阵再做近邻召回这一条链路走通推荐才谈得上可用。下文按数据层、算法层、服务层、评测层四段推进适合正在做课程设计、毕业设计或者要给中小型图书平台补推荐模块的开发者按步骤复现。2. 从借阅流水到用户-图书评分矩阵数据层的构建设计2.1 显式评分与隐式行为如何统一成一张评分表图书馆系统里通常两种数据并存少量书评打分显式大量借阅日志隐式。显式评分可信度高但样本少隐式行为样本多但噪声大。常见做法是分两路处理再合并显式评分按原值保留隐式行为按权重折算同一用户对同一本书取加权后的最大值避免一个人反复点同一本书把分数刷上去。权重不是拍脑袋定的它要能区分「翻了一下就还」和「借满一个月读完」。我一般按下面的区间起步再根据业务侧反馈微调。行为类型偏好含义建议权重备注借阅并读完强正反馈5借阅时长超过阈值才算借阅即还弱正反馈23明显低于读完需降权收藏 / 加入书架中等正反馈4意图明确无需再做衰减详情页浏览弱正反馈1同书多次浏览只算一次检索后未点击弱负反馈-0.5可选需谨慎使用负反馈在图书场景要克制。搜了没点可能只是没找到不代表不喜欢。真要用权重也别超过 -1否则会把用户的探索行为误判成反感。时间衰减同样重要。三年前借过的书和上个月借过的书对当前兴趣的指示强度完全不同。用半衰期做指数衰减是最省事的方案图书这种兴趣迁移慢的品类半衰期取 90 到 180 天都合理。import pandas as pd import numpy as np from scipy.sparse import csr_matrix BEHAVIOR_WEIGHT { borrow_finish: 5.0, # 借阅并读完 borrow_return: 2.5, # 借出即还 collect: 4.0, # 收藏 click: 1.0, # 详情页浏览 } HALF_LIFE_DAYS 120 # 时间衰减半衰期图书类兴趣迁移较慢 logs pd.read_csv(borrow_logs.csv, parse_dates[event_time]) logs[base] logs[behavior].map(BEHAVIOR_WEIGHT) # 时间衰减距今天数每过一个半衰期权重减半 days (logs[event_time].max() - logs[event_time]).dt.days logs[decay] 0.5 ** (days / HALF_LIFE_DAYS) logs[score] logs[base] * logs[decay] # 同一用户对同一本书的多次行为取最大值防止刷次数 user_book (logs.groupby([user_id, book_id])[score] .max() .reset_index()) user_book user_book[user_book[score] 0] # 保留正反馈这段代码的核心是三步映射权重、乘衰减因子、按用户-图书聚合。groupby(...).max()而不是sum()是因为重复行为代表的是「同一份兴趣被观察到多次」不是「兴趣翻倍」。如果业务上确实是多次借阅代表更强偏好可以把max换成对数压缩后的sum形如np.log1p(sum)避免分数无上限膨胀。2.2 用 pandas 构建评分矩阵并做数据体检拿到user_book之后不要直接转稠密矩阵。一个中等规模图书馆有几十万用户和上百万册图书稠密矩阵的内存占用会把进程直接撑爆。用scipy.sparse的 CSR 格式只存非零元素。# 类别编码记录原始 id 到矩阵下标的映射 user_cat user_book[user_id].astype(category) book_cat user_book[book_id].astype(category) row user_cat.cat.codes.to_numpy() col book_cat.cat.codes.to_numpy() val user_book[score].to_numpy() R csr_matrix((val, (row, col)), shape(len(user_cat.cat.categories), len(book_cat.cat.categories))) # 体检指标 nnz R.nnz m, n R.shape sparsity 1 - nnz / (m * n) avg_per_user nnz / m avg_per_book nnz / n print(f用户数{m} 图书数{n} 非零{nnz}) print(f稀疏度{sparsity:.6f} 人均行为{avg_per_user:.2f})参数和输出要盯住三件事。sparsity通常在 0.999 以上这是图书推荐的常态不用慌但要作为算法选型的输入。avg_per_user低于 5 说明用户行为太稀UserCF 的相似度会非常不稳。avg_per_book低于 2 说明绝大多数书只被一两个人碰过ItemCF 的共现矩阵会大面积为零必须有内容侧特征兜底。注意类别编码后的下标顺序必须和相似度矩阵的维度严格对应把cat.categories一起 pickle 落盘。上线后重新训练如果类别顺序变了模型和映射对不上推荐结果会张冠李戴而且这种错误在接口层完全看不出异常。2.3 稀疏度、长尾分布与冷启动的量化判断在做算法之前先把数据底子量化清楚否则后面调参全是盲调。下面这张表是我常用的判断清单每一项都有对应的动作。指标计算方式健康区间不达标时的动作矩阵稀疏度1 - nnz/(m×n)高于 99.9% 需谨慎引入内容特征混合召回人均行为数nnz/m≥ 10合并匿名会话补浏览行为图书平均行为数nnz/n≥ 3长尾书走内容相似度冷启动用户占比行为数3 的用户占比 20%热门榜 类目兜底头部集中度Top1% 图书行为占比 50%加流行度惩罚长尾分布是图书场景最显著的特征。借阅量前 1% 的图书往往吃掉 40% 到 60% 的行为如果不做处理任何协同过滤算法推出来的 Top10 都会集中在那几十本畅销书上用户看两次就失去新鲜感。惩罚方式很简单在最终打分时除以流行度的某个幂次形如score / pop**alphaalpha取 0.3 到 0.5 起步再根据推荐覆盖率的变化回调。冷启动用户占比超过两成时不要指望协同过滤能覆盖直接准备一套规则兜底按用户所属院系、年级、历史最高频类目拼一个初始列表等行为积累到 3 条以上再切回协同过滤通道。3. 协同过滤算法的选型与最小实现UserCF、ItemCF 与矩阵分解3.1 图书场景下 UserCF 与 ItemCF 的取舍依据UserCF 找的是「和你兴趣相似的人」ItemCF 找的是「和你读过这本书相似的书」。图书场景里我优先选 ItemCF理由有三条。第一图书的物品数远大于用户数且物品的相对稳定期长。一本书的相似邻居可以离线算好复用很久而用户兴趣会随课程、课题、季节变化用户相似度矩阵的保鲜期短得多。第二可解释性强。前端可以直接写「因为你读过《XXX》」用户能理解产品侧也方便做转化分析。UserCF 给出的理由是「和你相似的人也在看」在图书这种私密性较强的消费上说服力偏弱。第三物品侧的共现噪声比用户侧小。一个用户借了 20 本书兴趣可能横跨三个领域用户向量很杂而一本书被 200 个人借过它的读者画像相对聚焦相似度更可信。UserCF 也不是没用武之地。当用户量小、物品更新极快比如新书每日上架数百种、且需要强时效的「大家都在看」氛围时UserCF 的响应更灵敏。真要做用户数低于 1 万时矩阵规模可控计算量也能接受。3.2 相似度计算余弦、皮尔逊与修正余弦的差别三种相似度在图书场景的表现差异明显。余弦相似度只看向量方向不看长度对活跃用户天然友好但会把「借了 50 本书的人」和「借了 2 本书的人」放在同一尺度上比较。修正余弦Adjusted Cosine减去用户均分能抵消用户打分尺度差异代价是隐式反馈下「均分」的物理含义模糊。皮尔逊相关系数做的是中心化后的余弦在显式评分数据上表现最好但隐式反馈的矩阵里大量缺失值被当作 0中心化会把「没借过」和「讨厌」混为一谈反而拉低效果。工程上的常见做法是隐式反馈为主的图书数据用余弦配合收缩Shrinkage抑制小样本共现显式评分占比较高时才考虑皮尔逊。收缩的公式是sim * (co / (co shrink))co是共现次数shrink取 10 到 50。共现只有 1 次的两个物品相似度会被压到原来的十分之一附近避免「唯一一个同时借过这两本书的人」造成的假相关。3.3 手写 ItemCF 的最小可运行代码下面这段是物品侧协同过滤的核心输入是 2.2 节构建的R用户×图书先转置成物品×用户再算相似度。import numpy as np from scipy.sparse import csr_matrix def item_similarity(R_user_item, shrink20): R_user_item: 用户 x 图书 的稀疏评分矩阵返回图书 x 图书相似度 R R_user_item.T.tocsr() # 转成 物品 x 用户 co (R R.T).toarray() # 共现强度带评分权重 norms np.sqrt(np.asarray(R.multiply(R).sum(axis1)).ravel()) denom np.outer(norms, norms) denom[denom 0] 1e-9 # 防止除零 sim co / denom # 余弦相似度 # 收缩共现越少相似度越向 0 靠拢 sim sim * (co / (co shrink)) np.fill_diagonal(sim, 0.0) # 自身相似度置 0 return sim def topk_similarity(sim, k30): 每个物品只保留 top-k 个最相似邻居降噪又省内存 out np.zeros_like(sim) for i in range(sim.shape[0]): idx np.argpartition(sim[i], -k)[-k:] out[i, idx] sim[i, idx] return out def recommend(user_vec, sim, topn10): user_vec: 用户对图书的评分稀疏向量一维 scores np.asarray(sim user_vec.toarray().ravel()).ravel() seen user_vec.nonzero()[1] # 已产生过行为的图书下标 scores[seen] -np.inf # 过滤已读避免重复推荐 top np.argsort(scores)[::-1][:topn] # 取分数最高的 topn return [(int(i), float(scores[i])) for i in top]逻辑上分四步转置矩阵、算共现、归一化得余弦、收缩降噪。shrink20是起点共现次数低于 20 的物品对会被明显压制共现几百次的物品对几乎不受影响。topk_similarity的作用容易被低估。一个百万级物品的相似度矩阵是稠密的全量保存既不现实也没必要绝大多数相似度都是 0.001 量级的噪声。只留 top-30内存能降两个数量级同时因为去掉了大量弱邻居推荐质量反而更好。recommend里的scores[seen] -np.inf是最容易被漏掉的一行。不做过往过滤线上用户看到的推荐里全是他自己借过的书投诉会立刻涌来。3.4 K 近邻、相似度阈值与推荐个数三个必调参数参数作用起步值调大后果调小后果K近邻数参与预测的相似物品数2050覆盖率升精度可能降精度升长尾推荐减少相似度阈值低于该值不参与计算0.050.1结果更保守冷门书难出引入噪声推荐跑偏TopN推荐个数返回条数1020点击率降曝光成本升供给不足用户重复看到shrink收缩强度1050强压小样本共现假相关增多alpha流行度惩罚打压热门0.30.5长尾占比升精度可能降头部集中缺乏多样性调参的正确顺序是先固定 K30、阈值 0.05、TopN10 跑出基线然后单独动 K取精度曲线拐点再单独动阈值观察覆盖率变化最后加流行度惩罚同时看精度和覆盖率两个指标不能只看精度。注意所有参数都要在验证集上选不要用测试集调参。图书场景数据量本身不大一次调参过拟合就能让线上效果掉三成而且很难归因。3.5 矩阵分解补位SVD 处理稀疏与冷门图书近邻法在长尾物品上几乎无解一本书只有一两个用户碰过共现矩阵整行接近全零。矩阵分解能把用户和物品映射到低维隐空间靠全局信息补出分数。from sklearn.decomposition import TruncatedSVD import numpy as np # k64 是隐向量维度图书场景 32128 都常见 svd TruncatedSVD(n_components64, random_state42, n_iter7) U svd.fit_transform(R) # 用户隐向量 (m x k) V svd.components_ # 物品隐向量 (k x n) def mf_recommend(user_idx, topn10, seenNone): scores U[user_idx] V # 全量候选打分 if seen is not None: scores[seen] -np.inf top np.argsort(scores)[::-1][:topn] return [(int(i), float(scores[i])) for i in top]n_components控制隐空间维度。维度太低欠拟合推荐集中在少数几个大类维度太高会把噪声也学进去小数据量下尤其明显。用explained_variance_ratio_累计到 0.8 到 0.9 来定维度是个稳妥的起点。实践中推荐的组合是ItemCF 负责主召回SVD 负责长尾补充两路结果按分数归一化后加权融合权重用线上数据调。纯 SVD 的可解释性差纯 ItemCF 的长尾覆盖差两者互补。4. 推荐服务的工程化离线召回、在线排序与接口封装4.1 离线全量计算与增量更新的取舍图书推荐的计算可以完全离线线上只做查询。相似度矩阵每天重算一次用户推荐列表按活跃度分层刷新这样单次接口响应能压到毫秒级。# 每天凌晨 2 点全量重算相似度矩阵 0 2 * * * /usr/bin/python3 /opt/rec/train_full.py /var/log/rec/full.log 21 # 每 5 分钟只看最近有行为的用户增量刷新其推荐列表 */5 * * * * /usr/bin/python3 /opt/rec/refresh_active.py --window-min 30 /var/log/rec/inc.log 21train_full.py输出两个文件相似度矩阵item_sim.pkl和类别映射mapping.pkl两者必须同一批次落盘并带版本号。refresh_active.py只处理最近 30 分钟内有行为的用户用的是上一轮的全量相似度矩阵所以速度快、成本低。增量更新的边界要清楚新增图书没有共现数据相似度靠内容特征分类号、主题词、作者冷启等积累到阈值再并入协同过滤。全量重算周期不要超过一周否则长尾物品的相似度会随时间漂移。4.2 用 Flask 封装推荐接口并做结果缓存import json, pickle import numpy as np import redis from flask import Flask, jsonify, request app Flask(__name__) SIM pickle.load(open(/opt/rec/model/item_sim.pkl, rb)) MAP pickle.load(open(/opt/rec/model/mapping.pkl, rb)) R pickle.load(open(/opt/rec/model/matrix.pkl, rb)) r redis.Redis(host127.0.0.1, port6379, db0) app.route(/recommend/int:user_id) def recommend(user_id): topn min(int(request.args.get(topn, 10)), 50) # 限制上限防刷 key frec:u:{user_id}:{topn} cached r.get(key) if cached: return jsonify({source: cache, items: json.loads(cached)}) if user_id not in MAP[user_index]: # 冷启动用户 items hot_books(topn) source hot else: idx MAP[user_index][user_id] items cf_recommend(idx, topn) source cf r.setex(key, 3600, json.dumps(items)) # 缓存 1 小时 return jsonify({source: source, items: items})topn必须做上限截断min(..., 50)这一行是防御性设计防止外部传入超大值导致一次接口打满 CPU。缓存键里带上topn不然不同长度的请求会互相覆盖。setex的 3600 秒要和刷新任务周期匹配刷新周期是 5 分钟的话缓存 1 小时意味着用户最多看到一小时前的列表图书场景这个延迟完全可以接受。返回体里的source字段很实用线上排查时可以立刻区分「走了协同过滤」还是「兜底到热门」不用翻日志。4.3 冷启动与热门兜底的规则层设计hot_books不能简单取借阅量 TopN否则新用户看到的是和谁都不相关的畅销榜。按下面的优先级拼装更靠谱。场景策略数据来源完全新用户热门榜 类目均衡近 30 天借阅量按分类号分桶有院系/年级同院系热门院系维度的借阅统计有一条行为内容相似召回分类号、主题词、作者匹配有三条以上ItemCF 主通道相似度矩阵类目均衡是关键细节。热门榜如果全是文学类工科用户会直接关掉推荐位。常见做法是每个一级分类取前 3 本再混排保证列表的类目多样性。5. 效果验证与调优离线指标怎么看线上不达标先查哪里5.1 留一法与 K 折交叉验证的评测代码图书场景行为稀疏用留一法最合适每个用户最后一次行为作为测试集其余作训练集。import numpy as np def loo_split(user_book_df): 每个用户最后一条行为留作测试 df user_book_df.sort_values(event_time) test df.groupby(user_id).tail(1) train df.drop(test.index) return train, test def precision_recall_at_k(train, test, sim, k10): hits, total_rec, total_test 0, 0, len(test) test_map test.groupby(user_id)[book_id].apply(set).to_dict() for uid, group in train.groupby(user_id): if uid not in test_map: continue seen set(group[book_id]) scores recommend_for_user(uid, sim, k * 5) # 多召回再截断 recs [b for b, _ in scores if b not in seen][:k] total_rec len(recs) hits len(set(recs) test_map[uid]) precision hits / total_rec if total_rec else 0.0 recall hits / total_test if total_test else 0.0 return precision, recall留一法的好处是评测集规模等于用户数统计意义明确代价是测试集太薄单个用户的命中与否波动大。用户量少于 5000 时建议改用「每个用户留最后 20%」的时间切分指标更稳。5.2 PrecisionK、RecallK 与覆盖率一起看只看 Precision 会被热门策略骗过去全推畅销书Precision 也能到 0.1 以上但推荐毫无个性化。指标计算方式图书场景参考值说明Precision10命中数 / 推荐总数0.050.15越高越准但易受热门影响Recall10命中数 / 测试集总数0.080.20反映召回能力覆盖率被推荐图书数 / 总图书数≥ 30%低于 15% 说明头部集中类目多样性推荐列表中不同类目数≥ 3直接影响用户新鲜感NDCG10位置加权命中0.10.25越靠前命中权重越高Precision 和覆盖率要一起看。如果 Precision 涨了而覆盖率从 30% 掉到 8%说明算法在往热门收敛短期指标好看长期用户会疲劳。我一般要求两者的变化方向不能相反否则回退到上一版参数。5.3 推荐结果跑偏时的排查顺序线上效果不达标按这个顺序查基本能在半小时内定位。第一步查数据。最常见的隐形问题是时间穿越训练集里混进了测试期之后的行为离线指标虚高线上必然崩。检查event_time的过滤边界确认训练集的截止时间严格早于测试集起点。第二步查相似度矩阵。把np.count_nonzero(sim_per_row 0)打出来如果大量物品的邻居数为 0说明共现太稀shrink设太大了或者topk_similarity的k太小。同时确认np.fill_diagonal有没有执行对角线没置 0 会导致每个物品推荐自己。第三步查过滤逻辑。scores[seen] -np.inf如果漏写推荐列表里全是用过的书。用几个真实用户 ID 手工跑一遍把推荐结果和他们的历史记录对一遍五分钟就能确认。第四步查热门偏置。统计推荐结果中 Top1% 热门书的占比超过 70% 就加流行度惩罚alpha从 0.3 开始往上试每步 0.1。第五步才是调参。前三步没查清楚就动 K 和阈值等于在错误的基线上做优化改完也说不清是哪个变量起了作用。本文还有配套的精品资源点击获取
返回列表