简介:这是一份基于Python的图书推荐系统课程设计完整项目包,主要面向计算机相关专业大学生、Python初学者以及需要开发推荐系统实战项目的研究者。资源共33个文件,以16个.py源码文件为核心,涵盖数据清洗与特征工程、多种推荐算法实现(包括基于物品的协同过滤、SVD矩阵分解、LFM隐语义模型等),以及模型评估与结果导出等完整流程;另含10个CSV训练/测试数据集、4个XML配置文件和项目环境配置,压缩包整体约35.97MB。目前已有1489人学习下载。通过该项目,读者可以掌握Pandas数据处理、Scikit-learn与Surprise建模、Flask接口搭建等实用技能,同时可参考代码中交叉验证与调参思路,直接运行或二次开发,用于课程设计、毕业设计或推荐系统入门实践,具有很高的参考价值。
1. 先说判断:拿到 zip 之后别急着跑代码,先搞清数据长什么样
拿到一个名为基于python的图书推荐系统.zip的压缩包,很多人第一反应是解压、装依赖、python main.py,跑起来看到界面就以为完事了。这种流程在图书推荐这类教学项目上基本都会翻车:数据是现成的,模型是公开的,跑通只证明环境没配错,不代表你的推荐有用。真正决定这套系统价值的,是里面那套评分数据怎么清洗、相似度怎么算、新书和新用户怎么不被冷启动卡死。这篇笔记按我实际做过的方案讲,适合刚学完python基础语法、想拿完整项目练手的人,也适合要在一个 demo 上改出线上效果的开发者。你会从数据清洗一路走到离线评估,每一步都有能直接跑的代码。
2. 先看 zip 里的数据长什么样:三种表和评分归一化
2.1 zip 里最常见的三种表:用户、图书、评分/借阅记录
图书推荐系统的数据部分,绝大多数 zip 里装的是同一套东西:users.csv、books.csv、ratings.csv。不管压缩包里的界面写得有多花哨,最终喂给推荐算法的只有这三张表。
我习惯先打开ratings.csv看前几行,确认它是“显式评分”还是“隐式行为”。这两种数据对应完全不同的算法路线:
| 文件 | 典型字段 | 说明 |
|---|---|---|
| users.csv | user_id, age, occupation, gender | 用户画像,冷启动阶段会用到 |
| books.csv | book_id, isbn, title, author, category | 图书属性,基于内容推荐的主要原料 |
| ratings.csv | user_id, book_id, rating, created_at | 1-5分的显式评分,或0/1的借阅行为 |
如果 rating 列只有 0 和 1,说明数据是“借了/没借”,走隐式反馈路线,常用贝叶斯个性化排序(BPR)而不是普通的协同过滤评分预测。如果是 1-5 的连续分数,那就可以用后面要讲的评分预测思路。
拿到表以后别急着建模。先做一轮“体检”:看每张表的行数、缺失值、是否有重复的 user-book 对。重复评分在真实数据里很常见,同一个用户对同一本书打分两次,后一条往往覆盖前一条,但也可能是系统 bug 导致的数据重复。我处理重复值的默认做法是保留created_at最新的那条。
2.2 评分归一化:不处理用户打分尺度,算出来的相似度全是错的
评分数据最隐蔽的问题不是缺失,而是用户打分尺度不一样。有人只给 3-5 分,觉得“能看就行”;有人专给 1-2 分,觉得“没有满分就是差”。如果不做处理,相似度会被这种系统性偏差主导,两个审美完全不同的用户反而会因为打分区间的偏移被算成“相似”。
我一般会做用户均值中心化,把每个评分减去该用户的历史平均分:
import pandas as pd # 读三个原始表:用户、图书、评分 users = pd.read_csv("users.csv") books = pd.read_csv("books.csv") ratings = pd.read_csv("ratings.csv") # 评分表最常见的问题是用户ID和书ID带隐藏空格 ratings["user_id"] = ratings["user_id"].astype(str).str.strip() ratings["book_id"] = ratings["book_id"].astype(str).str.strip() # 评分只在 1-5 之间,越界直接筛掉 ratings = ratings[(ratings["rating"] >= 1) & (ratings["rating"] <= 5)] # 用户打分尺度不一样:有人只给3-5,有人专给1-2 # 这里做用户均值中心化,方便后面算相似度 user_mean = ratings.groupby("user_id")["rating"].transform("mean") ratings["rating_c"] = ratings["rating"] - user_mean # 去掉只打过一本书的用户,这种用户算相似度没有意义 valid_users = ratings.groupby("user_id")["rating"].count() ratings = ratings[ratings["user_id"].isin(valid_users[valid_users >= 5].index)]代码说明:第 4-5 行把 user_id 和 book_id 统一转成字符串再去空格,这步不做,后面构建矩阵时会因为“id 类型不一致”导致匹配失败。第 8 行过滤掉评分不在 1-5 范围内的异常记录。第 12-13 行是关键,groupby + transform("mean")会给每一行补上该用户的平均分,然后用原始分减去平均分得到中心化分数。第 16-17 行剔除“只打过一本书”的用户,这种用户的中心化分数恒为 0,参与相似度计算只会引入噪声。
中心化之后,评分变成“这本书比该用户平均偏好高多少分”。之前那个“只给 3-5 分”的用户,中心化后也会出现负分,和“专给 1-2 分”的用户就被拉到同一个尺度上了。这一步不做,后面算出来的相似度矩阵基本是废的。
2.3 数据质量检查:那些“一天打 500 本书”的用户是真实需求还是刷子
评分数据里还有一种常见污染:个别用户短时间内在几百本书上打了分,多半是导入脚本重复执行或测试账号刷数据。这种数据会严重扭曲热门度统计。我一般先按用户统计打分数量,画出分布,再决定截断阈值:
# 统计每个用户的评分数量,观察是不是有异常峰值 cnt = ratings.groupby("user_id")["rating"].count().sort_values(ascending=False) print(cnt.head(20)) # 常见做法是把评分数量超过 99.9% 分位数的用户视为异常 threshold = cnt.quantile(0.999) print("异常用户阈值:", threshold) # 如果存在明显的离群峰值,按阈值过滤 normal_users = cnt[cnt <= threshold].index ratings = ratings[ratings["user_id"].isin(normal_users)]这里用quantile(0.999)而不是写死“100 本”,是因为不同数据集分布差异很大,教学 zip 里的数据本来就不大,一刀切会把真实的高活跃用户误杀。过滤完再看一次行数,确认过滤比例在 5% 以内,超过就要怀疑是不是阈值取低了。
3. 用 python 实现协同过滤:相似度、预测评分和 top-N 召回
3.1 先选路线:user-based 推“和你像的人读的书”,item-based 推“和你读过的书相似的书”
协同过滤分两大流派。user-based 适合用户量大、评分足够稠密的场景;item-based 更适合图书这种“物品数量远大于用户数量”的场景——书有几万本,用户只有几百,算用户相似度的矩阵会非常稀疏。教学 zip 里用户通常只有几百到几千,两个流派都能跑,但效果差别明显。
我的选择标准很简单:用户数大于图书数,选 item-based;图书数大于用户数,选 user-based 反而矩阵更小。图书推荐系统的典型情况是图书数多,所以我会先按 user-based 写一版,因为它最直观,代码量最少,能快速验证数据管线有没有问题,再决定要不要切成 item-based。
还有一个折中方案是“先 user-based 算出候选,再按 item-based 排序”,也就是后面第 5 章要讲的混合召回。教学项目这么干不亏,推荐质量比单一算法稳定得多。
3.2 最小可跑的相似度计算:稀疏矩阵 + 余弦相似度
中心化后的评分表可以直接转成矩阵。千万别用 pandas 的pivot_table生成稠密 DataFrame——几百个用户乘几千本书就是上百万格子,九成以上是 0,白吃内存。正确做法是用scipy.sparse的 CSR 格式:
import numpy as np from scipy.sparse import csr_matrix # 把 user_id / book_id 映射成矩阵下标 user_ids = sorted(ratings["user_id"].unique()) book_ids = sorted(ratings["book_id"].unique()) uid = {u: i for i, u in enumerate(user_ids)} bid = {b: j for j, b in enumerate(book_ids)} row = ratings["user_id"].map(uid) col = ratings["book_id"].map(bid) # 用中心化后的评分填充矩阵,没打分的位留 0 mat = csr_matrix( (ratings["rating_c"].astype(float), (row, col)), shape=(len(user_ids), len(book_ids)), ) # 余弦相似度:先按行归一化,再乘转置矩阵 row_norm = np.sqrt(mat.multiply(mat).sum(axis=1)) mat_n = mat.multiply(1.0 / row_norm).tocsr() sim = mat_n @ mat_n.T # (用户数, 用户数),每行是与其他用户的相似度逻辑说明:csr_matrix的构造函数接收三个参数,分别是非零值、行号、列号。中心化分数作为矩阵的填充值,没打分的位不占内存。计算余弦相似度时不能直接对稀疏矩阵调用sklearn的cosine_similarity,那个接口会把输入转稠密,数据一大就爆内存;手写这四行就能避免。mat.multiply(mat)是逐元素平方,sum(axis=1)算出每个用户的向量长度,相除完成归一化,最后和自身的转置相乘得到相似度矩阵。
参数说明:这里的“向量长度”如果为 0,除出来是NaN。上面第 2.1 节已经剔除了只打过一本书的用户,所以基本不会出现全零行;万一出现,把这行的相似度手动置 0 就行。相似度矩阵是(用户数, 用户数)的稠密矩阵,教学数据几百个用户没问题,用户过万就得改成按块计算,后面避坑章节会细说。
3.3 预测评分和 top-N 推荐:KNN 参数怎么调
拿到相似度矩阵后,给用户u推荐书的过程分两步:先找出和u最像的 K 个用户,再把这 K 个用户打过分的书按相似度加权汇总,排除u已经读过的:
def predict_rating(u_idx, book_j, sim_matrix, mat, k=10): # u_idx: 目标用户的行号 # book_j: 目标图书的列号 neighbors = np.argsort(-sim_matrix[u_idx])[1:k+1] # 不包含自己 neighbor_scores = mat[neighbors, book_j].toarray().flatten() neighbor_weights = sim_matrix[u_idx, neighbors] # 只看打过这本书的邻居,避免 0 分拉低预测 mask = neighbor_scores != 0 if mask.sum() == 0: return 0.0 weights = neighbor_weights[mask] scores = neighbor_scores[mask] # 加权平均,中心化分数可能是负数,这里要保留符号 return float(np.dot(weights, scores) / weights.sum()) # 给用户 0 推荐的 top-10 图书 u_idx = 0 unrated = np.where(mat[u_idx].toarray().flatten() == 0)[0] pred = [predict_rating(u_idx, j, sim, mat, k=10) for j in unrated] top_idx = np.argsort(-np.array(pred))[:10] top_books = [book_ids[j] for j in unrated[top_idx]] print(top_books)k是最核心的参数。k=10表示用最像的 10 个邻居来预测,k太小预测波动大,k太大把“不那么像”的人也拉进来,推荐结果趋近热门榜。图书推荐数据稀疏时,我一般从k=20起步,候选书交叉验证后调。另外注意代码里mask.sum()的判断——如果 10 个邻居里一个都没读过目标书,直接返回 0 而不是报错,这个分支在稀疏数据里触发频率极高。
3.4 用 surprise 库重跑一遍:为什么要自己写一次再上库
手写版本跑通后,我会强烈建议你再试一下surprise这个库,它把 KNN、SVD 等算法都封装好了:
pip install scikit-surprisefrom surprise import Dataset, Reader, KNNBasic, accuracy from surprise.model_selection import cross_validate reader = Reader(rating_scale=(1, 5)) data = Dataset.load_from_df(ratings[["user_id", "book_id", "rating"]], reader) algo = KNNBasic(sim_options={"name": "cosine", "user_based": True}) cross_validate(algo, data, cv=3, verbose=True)Reader的rating_scale按你数据实际范围填,填错会导致所有预测被截断到错误区间。KNNBasic的sim_options里user_based=True就是 user-based 协同过滤,改成False就切到 item-based。cross_validate用的是随机切分,只能当流程验证,不能当最终评估——第 6 章会展开讲为什么。
自己写一遍再上库的顺序,是为了让你在surprise报错时不至于黑匣子抓瞎。库帮你省的是矩阵运算和交叉验证的样板代码,省不掉的是对数据和指标的理解。
4. 避坑记录:图书推荐项目必踩的五个坑
4.1 热门书刷屏:为什么每个人都收到同一批推荐
现象:top-N 推荐结果里,《活着》《三体》这类热门书占了七八个位置,个性化完全看不出来。原因:没有做评分偏差矫正。热门书被打分次数多,原始平均分天然偏高,协同过滤的邻居加权又放大了这个效应,最后变成了“热门榜复读机”。解决:用第 2 章的用户均值中心化分数替代原始分数参与相似度和预测计算。中心化之后,一个用户给热门书打了 5 分、平均分也是 5 分,这本书的贡献就是 0,必须“比平时更喜欢”才往上排。做完这一步推荐列表里就会出现真正符合个人偏好的书,代价是热门书名掉出榜单,这是正常现象。
4.2 新书黑洞:内容特征没进模型,新书永远推不出去
现象:刚上架的新书没有评分,协同过滤里它的列全为 0,任何用户都不可能被推荐到。原因:协同过滤只知道“谁给什么打过卡”,不知道书本身的属性。解决:补一路基于内容的召回,把书名、作者、分类拼成文本算相似度:
from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.metrics.pairwise import cosine_similarity # 把书名、作者、分类拼成一个长文本 books["text"] = books["title"] + " " + books["author"] + " " + books["category"] vec = TfidfVectorizer(token_pattern=r"\w+", max_features=5000) tf = vec.fit_transform(books["text"]) book_sim = cosine_similarity(tf) # 书与书的相似度矩阵逻辑说明:token_pattern=r"\w+"是为了适配中文书名——默认的英文分词规则会把中文整句当成一个 token,max_features=5000限制特征维度,防止拼出来的文本膨胀成几万维。cosine_similarity在这里接受的是稀疏矩阵,返回的是稠密矩阵,图书量超过一两万本就建议改成第 5 章那种增量算法。有了book_sim,新书就能通过“找最像的旧书,把旧书的协同过滤评分搬过来”被推出去。
4.3 离线指标好看,线上没人点:RMSE 低不等于推荐有用
现象:交叉验证 RMSE 降到 0.8 以下,上线后点击率反而掉了。原因:评分预测误差低说明“预测分贴近真实分”,但推荐列表需要的是“排序正确”。一个系统永远推用户看过的热门书,预测误差可以压得很低,可用户不需要被推荐已知的东西。解决:评估指标从 RMSE 换成排序类指标,同时盯覆盖率。第 6 章会给具体的评估流程,这里先记住一个原则:离线至少算一个precision@k,再算一个“推荐列表里不同书的数量/总书数”的覆盖率。覆盖率掉到 20% 以下,说明算法已经在复读热门了。
4.4 换台电脑就崩:pickle 模型文件是全套系统的后悔药
现象:模型训练好了,pickle.dump存档,换了一台电脑、换了个 python 版本,pickle.load直接报ModuleNotFoundError或者AttributeError。原因:pickle 把类的完整路径也存进去了,sklearn版本一升级,内部类名变了就找不到。解决:换用joblib或者干脆只保存模型的参数和权重:
import joblib joblib.dump(mat.tocsr(), "user_item_matrix.joblib") joblib.dump(vec, "tfidf_vec.joblib") # 恢复 mat = joblib.load("user_item_matrix.joblib")顺带一提,TfidfVectorizer这类带词表的模型,joblib也不是百分百跨版本兼容,最稳的做法是连vec.get_feature_names_out()一起导出一份词表文件,版本出问题还能手动重建。
4.5 评分矩阵大了内存先爆:稠密 DataFrame 是元凶
现象:用户和图书各到几千的量级,用pivot_table存评分矩阵,内存占用几个 G,训练还没开始先卡死。原因:pivot_table生成的是稠密矩阵,1000 用户乘 5000 本书就是 500 万个格子,float64 占 40MB,看着不大;但一旦做相似度计算,相似度矩阵是用户数 x 用户数,又会翻几十倍。解决:全程用scipy.sparse,已经在第 3 章写过了。另一个容易被忽视的点是csr_matrix @ csr_matrix.T的结果有可能会变成稠密矩阵,用户量到一万时就该用sklearn.metrics.pairwise.cosine_similarity的dense_output=False参数,或者按批次计算相似度。
5. 混合推荐与冷启动:把算法接成一条能上线的流水线
5.1 基于内容推荐:新书入库后如何做增量向量更新
第 4 章 4.2 那三行代码只能算“验证基本功”。真实项目里,图书会持续上新,每次都全量重算book_sim是浪费。正确做法是保留训练好的TfidfVectorizer,新书只做transform,然后只更新新书和全量书的相似度:
from scipy.sparse import vstack # vec 是之前 fit 好的 TfidfVectorizer,old_tf 是旧书的向量矩阵 def update_book_sim(new_books_df, old_tf, old_book_sim, old_books_df, vec): # 新书拼文本,注意拼接格式必须和训练时完全一致 new_text = ( new_books_df["title"] + " " + new_books_df["author"] + " " + new_books_df["category"] ) new_tf = vec.transform(new_text) tf_all = vstack([old_tf, new_tf]) # 只算新书与全量书的相似度,旧书之间的相似度不需要重算 new_sim = cosine_similarity(new_tf, tf_all) # 返回新书和旧书集合的相似度映射 # 召回模块实时查询:新书 -> 最像的 K 本旧书 -> 复用旧书的协同过滤评分 old_ids = list(old_books_df["book_id"]) new_ids = list(new_books_df["book_id"]) result = {} for i, nid in enumerate(new_ids): # 相似度最高的前 10 本旧书 top_old = np.argsort(-new_sim[i])[:10] result[nid] = [(old_ids[t], new_sim[i, t]) for t in top_old] return result逻辑说明:vec.transform用的是训练时的词表,新书里出现没见过的词会被忽略,不会改变向量维度,所以vstack能直接拼接。cosine_similarity(new_tf, tf_all)的返回值是(新书数, 全量书数),我们只需要第一段,也就是新书和旧书之间的相似度。result里存的是“最像的 10 本旧书”,推荐系统看图的时候,把旧书的协同过滤推荐分数乘上这个相似度,就完成了冷启动的“平滑过渡”。
参数说明:[:10]这个邻居数可以和协同过滤的k分开调。内容相似度只用来做“搭桥”,不需要取太多,5-10 本就够;取多了会把不相关的书拖进来,比如作者同名但题材完全不同的书。
5.2 混合加权:协同过滤和内容召回怎么合并不打架
内容召回解决了新书,但它也有短板——它只看书的属性,不看用户偏好。两个召回源各说各话,直接相加原始分是没有意义的,因为协同过滤输出的是评分(1-5 尺度),内容召回输出的是相似度(0-1 尺度)。我合并的标准做法是转成排名再加权:
def hybrid_recall(user_id, cf_scores, cb_scores, top_n=20, w=0.6): # cf_scores: dict {book_id: score},协同过滤召回结果 # cb_scores: dict {book_id: score},内容召回结果 # 两个来源的分数不在同一个尺度,先各自算排名分(排名越小越靠前) def rank_norm(scores): ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True) return {b: r for r, (b, _) in enumerate(ranked)} cf_rank = rank_norm(cf_scores) cb_rank = rank_norm(cb_scores) # 没被某一路召回的书,给一个惩罚排名而不是直接忽略 cf_penalty = len(cf_rank) + 1 cb_penalty = len(cb_rank) + 1 merged = {} for b in set(cf_rank) | set(cb_rank): cf_r = cf_rank.get(b, cf_penalty) cb_r = cb_rank.get(b, cb_penalty) merged[b] = w * cf_r + (1 - w) * cb_r # 排名分越小越靠前,按升序取前 top_n return sorted(merged.items(), key=lambda x: x[1])[:top_n]w是协同过滤的权重,取值范围 0-1。教学项目从 0.6 起步,因为协同过滤代表真实行为,权重理应更高。但新书较多、需要靠内容带量的场景,w要降到 0.4 左右。怎么定?跑一版时间切分评估,看precision@k和覆盖率随w的走向,两条曲线的交叉点就是合理的权重。这个实验值得做,它比凭感觉调参有效得多。
5.3 冷启动三步走:新用户、新书、新站点的数据从哪来
冷启动是三个场景,不要混在一起解决。
新用户没有行为记录,协同过滤直接废掉。常见做法是分两步:注册时选择兴趣标签,把标签映射到图书分类,先推每个分类下的热门高分书;有了几次点击或打分记录后,立刻切换到混合召回。这个切换的阈值我用的是“用户有 5 条有效评分记录”,低于 5 条就走兴趣标签推荐。
新书没有评分记录,靠第 5.1 节的增量内容相似度搭桥。如果连内容文本也不全,就把新书悄悄埋进推荐列表的尾部,也就是不主动曝光,等它积攒到第一条评分。
新站点没有任何历史数据,这是最难的情况。常见做法是人工精选一批高质量书目做基础推荐,外加收集用户的注册兴趣标签。有些项目会用 python 爬虫抓豆瓣、当当的图书评分来做冷启动数据源,技术上可行,但要注意目标站的访问规则和数据使用边界,抓回来的数据要人工抽检,防止脏数据污染整个模型。我建议先跑通用户兴趣标签这一路,成本最低,见效最快。
6. 离线评估与上线后的验证:怎么判断推荐真的变好了
6.1 时间切分:随机切分算出来的指标不靠谱
教学项目默认的交叉验证是随机切分,但推荐系统里用户行为有强烈的时间依赖——用户上个月读的书会决定这个月读什么。随机切分会把“未来”混进训练集,评估结果虚高。我上线的方案一定用时间切分:
# 正确做法:按时间切分,而不是随机切分 ratings = ratings.sort_values("created_at") cut_idx = int(len(ratings) * 0.8) train = ratings.iloc[:cut_idx] # 前 80% 时间内的行为 test = ratings.iloc[cut_idx:] # 后 20% 时间内的行为 # 检查训练集里有没有“偷看”测试集的书 visited_books_train = set(train["book_id"]) leak = sum(1 for b in test["book_id"] if b not in visited_books_train) print("测试集里训练集没见过的书占比:", leak / len(test))代码逻辑不复杂,但created_at列不是标准时间戳的情况很常见,需要先pd.to_datetime清洗。如果测试集里“训练集没见过的书”占比过高,说明数据太稀疏或时间跨度太短,这时候直接上协同过滤是没意义的,要优先做内容召回。
6.2 上线后必看的三个指标:精度、覆盖率、多样性
离线跑完,上线以后建议按周画三条曲线,分别对应三个不同维度的健康状态:
| 指标 | 计算方式 | 看什么 |
|---|---|---|
| precision@k | 推荐列表里被点击的比例 | 推荐是否贴合用户当前需求 |
| 覆盖率 | 被推荐过的书数 / 全书库书数 | 是否只在热门书里打转 |
| 多样性 | 推荐列表里不同分类的书数 | 用户是不是总收到同质内容 |
三个指标要一起看。只看 precision,算法容易收敛到“只推最稳的热门书”;只看覆盖率,容易为了覆盖而牺牲准确率。我自己常用的经验线:覆盖率低于 20% 就怀疑热门刷屏,多样性低于 3 个分类就该检查是不是相似度算法把同作者同分类的书全部顶上来了。
6.3 一个防数据泄露的小技巧:行为时间戳检查
上线前最后一步,我习惯做一个数据泄漏检查:把训练集里用户打分的时间分布画出来,如果发现“用户在测试集时间段内的行为出现在训练集里”,说明日志导入顺序乱了。另一个更隐蔽的场景是同一用户在同一秒内给几十本书打分,这多半是刷接口的批量操作,应该从训练数据里剔除。这个小检查用 pandas 一行就能做:
# 找到同一用户同一秒内打分超过 10 条的批量行为 batch_ops = ( ratings.groupby(["user_id", "created_at"])["book_id"] .count() .reset_index() ) bad_users = batch_ops[batch_ops["book_id"] > 10]["user_id"].unique() ratings = ratings[~ratings["user_id"].isin(bad_users)]我的习惯是每次上线前把时间切分的结果和随机切分的结果并排贴在评估记录里,如果时间切分掉得太多,这个版本我不会让它进生产环境。推荐系统的改进是一场长期实验,评估流程不立住,后面调再多参数都是自欺欺人。希望帮到你。
本文还有配套的精品资源,点击获取