尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

基于Python的图书推荐系统实战:协同过滤与冷启动处理

基于Python的图书推荐系统实战:协同过滤与冷启动处理 简介这是一份基于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.csvuser_id, age, occupation, gender用户画像冷启动阶段会用到books.csvbook_id, isbn, title, author, category图书属性基于内容推荐的主要原料ratings.csvuser_id, book_id, rating, created_at1-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(ascendingFalse) 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(axis1)) mat_n mat.multiply(1.0 / row_norm).tocsr() sim mat_n mat_n.T # (用户数, 用户数)每行是与其他用户的相似度逻辑说明csr_matrix的构造函数接收三个参数分别是非零值、行号、列号。中心化分数作为矩阵的填充值没打分的位不占内存。计算余弦相似度时不能直接对稀疏矩阵调用sklearn的cosine_similarity那个接口会把输入转稠密数据一大就爆内存手写这四行就能避免。mat.multiply(mat)是逐元素平方sum(axis1)算出每个用户的向量长度相除完成归一化最后和自身的转置相乘得到相似度矩阵。参数说明这里的“向量长度”如果为 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, k10): # u_idx: 目标用户的行号 # book_j: 目标图书的列号 neighbors np.argsort(-sim_matrix[u_idx])[1:k1] # 不包含自己 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, k10) 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是最核心的参数。k10表示用最像的 10 个邻居来预测k太小预测波动大k太大把“不那么像”的人也拉进来推荐结果趋近热门榜。图书推荐数据稀疏时我一般从k20起步候选书交叉验证后调。另外注意代码里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, cv3, verboseTrue)Reader的rating_scale按你数据实际范围填填错会导致所有预测被截断到错误区间。KNNBasic的sim_options里user_basedTrue就是 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_patternr\w, max_features5000) tf vec.fit_transform(books[text]) book_sim cosine_similarity(tf) # 书与书的相似度矩阵逻辑说明token_patternr\w是为了适配中文书名——默认的英文分词规则会把中文整句当成一个 tokenmax_features5000限制特征维度防止拼出来的文本膨胀成几万维。cosine_similarity在这里接受的是稀疏矩阵返回的是稠密矩阵图书量超过一两万本就建议改成第 5 章那种增量算法。有了book_sim新书就能通过“找最像的旧书把旧书的协同过滤评分搬过来”被推出去。4.3 离线指标好看线上没人点RMSE 低不等于推荐有用现象交叉验证 RMSE 降到 0.8 以下上线后点击率反而掉了。原因评分预测误差低说明“预测分贴近真实分”但推荐列表需要的是“排序正确”。一个系统永远推用户看过的热门书预测误差可以压得很低可用户不需要被推荐已知的东西。解决评估指标从 RMSE 换成排序类指标同时盯覆盖率。第 6 章会给具体的评估流程这里先记住一个原则离线至少算一个precisionk再算一个“推荐列表里不同书的数量/总书数”的覆盖率。覆盖率掉到 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_outputFalse参数或者按批次计算相似度。5. 混合推荐与冷启动把算法接成一条能上线的流水线5.1 基于内容推荐新书入库后如何做增量向量更新第 4 章 4.2 那三行代码只能算“验证基本功”。真实项目里图书会持续上新每次都全量重算book_sim是浪费。正确做法是保留训练好的TfidfVectorizer新书只做transform然后只更新新书和全量书的相似度from scipy.sparse import vstack # vec 是之前 fit 好的 TfidfVectorizerold_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_n20, w0.6): # cf_scores: dict {book_id: score}协同过滤召回结果 # cb_scores: dict {book_id: score}内容召回结果 # 两个来源的分数不在同一个尺度先各自算排名分排名越小越靠前 def rank_norm(scores): ranked sorted(scores.items(), keylambda x: x[1], reverseTrue) 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(), keylambda x: x[1])[:top_n]w是协同过滤的权重取值范围 0-1。教学项目从 0.6 起步因为协同过滤代表真实行为权重理应更高。但新书较多、需要靠内容带量的场景w要降到 0.4 左右。怎么定跑一版时间切分评估看precisionk和覆盖率随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 上线后必看的三个指标精度、覆盖率、多样性离线跑完上线以后建议按周画三条曲线分别对应三个不同维度的健康状态指标计算方式看什么precisionk推荐列表里被点击的比例推荐是否贴合用户当前需求覆盖率被推荐过的书数 / 全书库书数是否只在热门书里打转多样性推荐列表里不同分类的书数用户是不是总收到同质内容三个指标要一起看。只看 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)]我的习惯是每次上线前把时间切分的结果和随机切分的结果并排贴在评估记录里如果时间切分掉得太多这个版本我不会让它进生产环境。推荐系统的改进是一场长期实验评估流程不立住后面调再多参数都是自欺欺人。希望帮到你。本文还有配套的精品资源点击获取
返回列表