简介:这是一篇西南财经大学学士学位毕业论文,主题为基于协同过滤算法的图书推荐系统设计与实现,面向计算机科学、数据科学、人工智能等专业的本科生、研究生及对推荐算法感兴趣的科研人员。论文系统梳理了用户-用户与物品-物品两类协同过滤方法的原理与差异,并结合图书推荐场景,完整呈现数据收集、清洗、特征提取、相似度计算、推荐生成与反馈优化的全流程设计。资源为单个docx文档,压缩包约30KB,篇幅覆盖引言、相关技术综述、系统设计与实现、实验与评估、系统性能与优化等完整章节,可直接作为毕业设计参考模板使用。文中给出总体分层架构、数据处理与推荐算法模块的详细说明,并围绕推荐准确率、覆盖率、多样性等指标开展实验与结果分析,同时讨论协同过滤的优缺点及矩阵分解、深度学习等延伸方向。目前已有546人学习,适合需要快速搭建研究框架、理解算法落地细节并撰写论文的读者参考。
1. 图书推荐系统的第一道坎:为什么借阅记录比评分更能说明问题
图书馆或图书电商的推荐模块,上线前最容易被忽略的一点是:用户几乎从不打分。1 到 5 星的评分表在后台翻出来往往是空的,能拿到的全是借阅、续借、收藏、加入书架、详情页停留这类行为流水。协同过滤算法本身依赖「用户-物品-偏好」三元组,直接把点击当成 5 分塞进去,推荐结果会被蹭到的热门书带偏;完全不处理,矩阵里连可算的共现都凑不出来。
这就是「基于协同过滤算法的图书推荐系统设计与实现」真正要面对的工程问题:数据是隐式反馈、物品是长尾分布、用户是低频访问。把行为折算成偏好强度,构造成可计算的评分矩阵,再做近邻召回,这一条链路走通,推荐才谈得上可用。下文按数据层、算法层、服务层、评测层四段推进,适合正在做课程设计、毕业设计,或者要给中小型图书平台补推荐模块的开发者按步骤复现。
2. 从借阅流水到用户-图书评分矩阵:数据层的构建设计
2.1 显式评分与隐式行为如何统一成一张评分表
图书馆系统里通常两种数据并存:少量书评打分(显式),大量借阅日志(隐式)。显式评分可信度高但样本少,隐式行为样本多但噪声大。常见做法是分两路处理再合并:显式评分按原值保留,隐式行为按权重折算,同一用户对同一本书取加权后的最大值,避免一个人反复点同一本书把分数刷上去。
权重不是拍脑袋定的,它要能区分「翻了一下就还」和「借满一个月读完」。我一般按下面的区间起步,再根据业务侧反馈微调。
| 行为类型 | 偏好含义 | 建议权重 | 备注 |
|---|---|---|---|
| 借阅并读完 | 强正反馈 | 5 | 借阅时长超过阈值才算 |
| 借阅即还 | 弱正反馈 | 2~3 | 明显低于读完,需降权 |
| 收藏 / 加入书架 | 中等正反馈 | 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**alpha,alpha取 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, shrink=20): """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(axis=1)).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, k=30): """每个物品只保留 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, topn=10): """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]逻辑上分四步:转置矩阵、算共现、归一化得余弦、收缩降噪。shrink=20是起点,共现次数低于 20 的物品对会被明显压制,共现几百次的物品对几乎不受影响。
topk_similarity的作用容易被低估。一个百万级物品的相似度矩阵是稠密的,全量保存既不现实也没必要,绝大多数相似度都是 0.001 量级的噪声。只留 top-30,内存能降两个数量级,同时因为去掉了大量弱邻居,推荐质量反而更好。
recommend里的scores[seen] = -np.inf是最容易被漏掉的一行。不做过往过滤,线上用户看到的推荐里全是他自己借过的书,投诉会立刻涌来。
3.4 K 近邻、相似度阈值与推荐个数三个必调参数
| 参数 | 作用 | 起步值 | 调大后果 | 调小后果 |
|---|---|---|---|---|
| K(近邻数) | 参与预测的相似物品数 | 20~50 | 覆盖率升,精度可能降 | 精度升,长尾推荐减少 |
| 相似度阈值 | 低于该值不参与计算 | 0.05~0.1 | 结果更保守,冷门书难出 | 引入噪声,推荐跑偏 |
| TopN(推荐个数) | 返回条数 | 10~20 | 点击率降,曝光成本升 | 供给不足,用户重复看到 |
| shrink | 收缩强度 | 10~50 | 强压小样本共现 | 假相关增多 |
| alpha(流行度惩罚) | 打压热门 | 0.3~0.5 | 长尾占比升,精度可能降 | 头部集中,缺乏多样性 |
调参的正确顺序是:先固定 K=30、阈值 0.05、TopN=10 跑出基线,然后单独动 K,取精度曲线拐点;再单独动阈值,观察覆盖率变化;最后加流行度惩罚,同时看精度和覆盖率两个指标,不能只看精度。
注意:所有参数都要在验证集上选,不要用测试集调参。图书场景数据量本身不大,一次调参过拟合就能让线上效果掉三成,而且很难归因。
3.5 矩阵分解补位:SVD 处理稀疏与冷门图书
近邻法在长尾物品上几乎无解,一本书只有一两个用户碰过,共现矩阵整行接近全零。矩阵分解能把用户和物品映射到低维隐空间,靠全局信息补出分数。
from sklearn.decomposition import TruncatedSVD import numpy as np # k=64 是隐向量维度,图书场景 32~128 都常见 svd = TruncatedSVD(n_components=64, random_state=42, n_iter=7) U = svd.fit_transform(R) # 用户隐向量 (m x k) V = svd.components_ # 物品隐向量 (k x n) def mf_recommend(user_idx, topn=10, seen=None): 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 2>&1 # 每 5 分钟只看最近有行为的用户,增量刷新其推荐列表 */5 * * * * /usr/bin/python3 /opt/rec/refresh_active.py --window-min 30 >> /var/log/rec/inc.log 2>&1train_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(host="127.0.0.1", port=6379, db=0) @app.route("/recommend/<int:user_id>") def recommend(user_id): topn = min(int(request.args.get("topn", 10)), 50) # 限制上限,防刷 key = f"rec: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, k=10): 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 Precision@K、Recall@K 与覆盖率一起看
只看 Precision 会被热门策略骗过去:全推畅销书,Precision 也能到 0.1 以上,但推荐毫无个性化。
| 指标 | 计算方式 | 图书场景参考值 | 说明 |
|---|---|---|---|
| Precision@10 | 命中数 / 推荐总数 | 0.05~0.15 | 越高越准,但易受热门影响 |
| Recall@10 | 命中数 / 测试集总数 | 0.08~0.20 | 反映召回能力 |
| 覆盖率 | 被推荐图书数 / 总图书数 | ≥ 30% | 低于 15% 说明头部集中 |
| 类目多样性 | 推荐列表中不同类目数 | ≥ 3 | 直接影响用户新鲜感 |
| NDCG@10 | 位置加权命中 | 0.1~0.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 和阈值,等于在错误的基线上做优化,改完也说不清是哪个变量起了作用。
本文还有配套的精品资源,点击获取