简介:一套基于Python与Django的图书推荐系统毕设源码,使用豆瓣图书与评分数据,采用基于物品的协同过滤算法,实现从数据读取、相似度计算到推荐展示的完整流程。适合计算机、人工智能、通信工程、自动化、电子信息等专业学生,适用于毕业设计、课程设计、项目答辩或入门学习,代码结构清晰,便于在原有基础上修改功能。压缩包内共包含52个文件,总大小约797KB,其中20个Python源码和17个编译后的pyc文件构成核心代码,另有HTML页面、CSS样式、XML配置文档以及README说明文件,附带评分数据文件,可直接用于本地部署调试。项目代码经过运行测试且答辩评审平均分达96分,当前已有78人学习浏览;下载后可以重点阅读协同过滤算法相关模块,也可以通过替换数据集、调整相似度参数或增删功能模块,快速构建属于自己的图书推荐演示系统。
1. 基于商品的协同过滤:图书推荐为什么先做ItemCF而不是UserCF
拿Python做基于商品的协同过滤图书推荐系统,是课程设计和入门推荐系统最常见的选题之一。它不依赖深度学习,也用不上GPU,pandas和numpy就能跑通全流程:从用户对图书的评分数据出发,算出书与书之间的相似度,再给目标用户推荐他还没读过的书。这类项目最核心的价值不在算法新,而在数据、公式、代码和评估能形成一个完整闭环——这也正是「源代码+数据集+文档说明」能真正落地的基础。适合准备毕设、想从零搭一套可运行推荐系统的人。需要先说清楚一个反直觉的结论:图书场景下,基于商品的协同过滤(ItemCF)通常比基于用户的协同过滤(UserCF)更实用,下面从头拆解。
2. 从评分矩阵到相似度:余弦与皮尔逊两个公式决定结果上限
推荐系统的输入是一张用户-物品评分矩阵,行是用户,列是图书,单元格是评分。ItemCF的思路就一句话:如果两本书经常被同一批用户评高分,就认为它们相似;用户喜欢其中一本,就顺势推荐另一本。这个思路听起来简单,但落地时选什么相似度、怎么处理稀疏数据、预测分怎么算,每一步都会显著影响推荐结果。
推荐系统里用户行为数据永远是稀疏的。一个读者可能读过几百本书,但图书库里有几十万种书,他评过分的比例往往不到千分之一。在这样一张矩阵上找“相似用户”,结果通常不可靠:你和某个陌生人唯一的共同点可能就是都评过《三体》,但你们对科幻之外的书兴趣可能完全不同。UserCF的邻居集合又小又飘,用户兴趣一变,推荐结果立刻跟着变。
ItemCF恰好相反,它计算的是物品之间的共现关系。一本书的口碑和读者群相对稳定,几百个读者对它的评分足以支撑可靠的相似度计算;而且相似度矩阵可以离线算好、长期复用,在线推荐时只需要查表和做加权平均,响应非常快。从解释性上看,“因为您读过A,而读过A的人也有不少人喜欢B”也比“和您兴趣相似的用户还读过B”更容易让用户接受。所以在图书这种物品相对稳定、用户行为稀疏的场景,先做ItemCF,方向是对的。
2.1 相似度计算公式:余弦相似度与皮尔逊相关系数的差别
物品相似度最常用的指标是余弦相似度:把两个物品在用户维度上的评分向量拿出来,计算它们夹角的余弦值。公式写出来就是 cos(A, B) = (A·B) / (|A| × |B|),值域在 -1 到 1 之间,越接近 1 表示两个物品的评分越一致。
余弦相似度有一个天生的盲区:它不关心用户的评分尺度。同样打 4 分,对用户甲来说可能是“很满意”,对从不给 5 分的用户乙来说已经是“顶级好评”。为了消除这种个人偏差,实用中更常用皮尔逊相关系数:先对每个用户的评分减去他自己的平均分,再算余弦。我在项目里通常两个函数都会写,方便切换对比:
import numpy as np def cosine_sim(vec_a, vec_b): # 只保留两个物品都被评过分的位置,未评分的NaN先剔除 mask = vec_a.notna() & vec_b.notna() if mask.sum() == 0: return 0.0 a = vec_a[mask].values b = vec_b[mask].values return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-9)) def pearson_sim(vec_a, vec_b): # 中心化后做余弦,就是皮尔逊相关系数 mask = vec_a.notna() & vec_b.notna() if mask.sum() < 2: # 共评分太少时相关不可信 return 0.0 a = vec_a[mask].values - vec_a[mask].mean() b = vec_b[mask].values - vec_b[mask].mean() denom = np.linalg.norm(a) * np.linalg.norm(b) + 1e-9 return float(np.dot(a, b) / denom)参数说明里有两个边界值得记住。mask 确保计算只用两个物品共同被评过的分数,把 0 当作评分会引入“用户不喜欢”的虚假信号;mask.sum() < 2 时直接返回 0,是因为两个点的相关系数要么 +1 要么 -1,这种伪完美的相关性能污染整个相似度矩阵。1e-9 只是防止某个物品向量长度恰好为 0 时除零报错。
皮尔逊相关系数的坑在于它对中心化很敏感。如果某本书只被两三个用户评过分,减去均值后向量几乎只有两个非零分量,算出来的相关系数经常接近 ±1,看起来完美相似,实际是噪声。解决思路会在第 3 章的数据过滤和第 4 章的相似度矩阵构建里处理,核心就是设置最小共评数阈值。
2.2 预测评分公式:加权平均而不是直接取相似物品的评分
物品相似度算完,推荐靠的是预测评分。对用户 u 和未读过的书 i,预测分数等于用户 u 评过分的那些书 j 的评分,按书 j 与书 i 的相似度加权平均,公式是 ŷ = Σ(sim(i,j) × r_uj) / Σ|sim(i,j)|。分母用相似度的绝对值求和,是为了让预测值回到用户评分尺度,不会被高分书带偏。
写推荐函数的时候,我习惯先取出用户评过分的所有书,逐本找它的 Top-K 相似书,把相似度和评分乘起来累加,最后归一化。下面是一段可以直接在 Jupyter 里跑通的演示代码:
import pandas as pd # 模拟一个6条评分的小样本:用户1读过b1、b2、b3 ratings = pd.DataFrame([ (1, 'b1', 5), (1, 'b2', 4), (1, 'b3', 3), (2, 'b1', 4), (2, 'b2', 5), (3, 'b2', 3), (3, 'b3', 5), ], columns=['user_id', 'book_id', 'rating']) matrix = ratings.pivot_table(index='user_id', columns='book_id', values='rating') # 简化演示:只算b2和b3的皮尔逊相似度 sim_23 = pearson_sim(matrix['b2'], matrix['b3']) print(f"b2与b3的皮尔逊相似度: {sim_23:.2f}") # 预测用户1对b4的评分:用户1读过的书里,只有b2、b3与b4有相似度历史 # 这里为了演示,直接把b4与b2、b3的相似度假设为已知值 sim_42, sim_43 = 0.6, 0.8 pred = (sim_42 * 4 + sim_43 * 3) / (abs(sim_42) + abs(sim_43)) print(f"预测用户1对b4的评分: {pred:.2f}")逻辑说明:预测公式的分子是“相似度 × 真实评分”的加权和,分母是相似度绝对值之和。如果分母只用正相似度求和,一旦相似度出现负值,预测会莫名其妙地偏高或偏低,所以工程实现里我一般只保留正相似度的物品参与预测,负相关的书直接丢弃。参数方面,参与加权的邻居数量 k 通常取 20~50;图书推荐这种长尾明显的场景,k 取 20 比取 50 更稳,因为相似度排序越靠后的书,噪声占比越高。
2.3 余弦和皮尔逊在实际项目中怎么选
我的经验是:小数据集上两者差异不大,但皮尔逊更抗用户打分尺度差异。课程设计数据集通常只有几千条评分,用户打分习惯差异明显——有人只打 3~5 分,有人 1~10 分都打,不中心化的话,余弦相似度会被“手松”的用户主导。
如果做的是隐式反馈(比如用户是否借阅过某本书),评分只有 0/1,这时候皮尔逊和余弦几乎等价,直接用余弦即可。显式评分(1~5 分或 1~10 分)优先皮尔逊。另外,无论选哪种,都要配合一个最小共现次数过滤,否则稀疏矩阵上算出来的相似度大部分是噪声。这一条放到后面的避坑章节里展开。
3. 图书数据集清洗与评分矩阵构建:稀疏度检查与最小行为阈值
算法原理只是前半程,真实项目中 60% 的时间花在数据上。图书推荐系统的常见数据集由两个文件组成:评分表(用户 ID、ISBN、评分)和图书信息表(ISBN、书名、作者、出版社、出版年份)。评分分值可能是 1~10 的显式评分,也可能是表示“是否读过”的 0/1 隐式反馈。拿到数据后的第一步不是建模,而是检查体量、去重、梳理分布。
我一般先跑一段快速探查,看清数据到底能不能用:
import pandas as pd ratings = pd.read_csv('ratings.csv', names=['user_id', 'isbn', 'rating']) print(ratings.shape) print(ratings.head()) print(ratings.isna().sum()) print(f"用户数: {ratings['user_id'].nunique()}, 图书数: {ratings['isbn'].nunique()}")字段说明可以用一张表记在项目文档里,方便答辩和后续维护:user_id 是用户唯一标识,可能是字符串也可能是数字;isbn 是图书的国际标准书号,注意同一本书的精装、平装、影印版 ISBN 不同;rating 是评分,显式评分常见 1-5 或 1-10,0 分通常表示“读过但没打星”。这个表也是“文档说明”里最值得写清楚的部分,后面读代码的人靠它理解数据口径。
3.1 去重与ID压缩:把字符串ID变成连续的整数编码
评分数据最常见的脏数据是重复行:同一用户对同一本书录入了多次。共享数据集里这种重复往往来自数据合并或用户反复修改评分。处理方式一般是保留最后一次记录,或者直接取均值。我倾向保留最后一次,因为用户最终评分代表他的真实态度。
去重之后做 ID 压缩。原始用户 ID 和 ISBN 是字符串,直接用它们做 pivot 会产生巨大且稀疏的列索引,内存浪费严重。常见做法是转成 pandas 的 category 类型再取编码:
# 去重:同一用户对同一本书的评分保留最后一条 ratings = ratings.drop_duplicates(subset=['user_id', 'isbn'], keep='last') # 压缩ID:category编码比LabelEncoder更快,且保留原始ID对应关系 ratings['uid'] = ratings['user_id'].astype('category').cat.codes ratings['iid'] = ratings['isbn'].astype('category').cat.codes参数说明:subset 指定去重判定的列,keep='last' 是保留最后一条。astype('category') 之后取 cat.codes 会把每个 ID 映射成 0 到 n-1 的连续整数,比手写字典映射更不容易出错,而且内存占用明显更小。注意要保留一份 uid 到 user_id、iid 到 isbn 的映射表,后面推荐结果要还原成书名时用得上。
3.2 评分矩阵与稀疏度计算:fillna(0)不是第一步
接下来把长表转成宽表,也就是用户×图书的评分矩阵。这一步最关键的决策是:缺失值先保留 NaN,不要急着 fillna(0)。一旦过早填充 0,稀疏度统计会失真,相似度计算也会把“用户没读过这本书”错误理解为“用户给了 0 分”。
# 长表转宽表:行是用户,列是图书,值是评分 matrix = ratings.pivot_table(index='uid', columns='iid', values='rating') # 计算稀疏度:缺失值比例越高,用户行为越稀疏 sparsity = 1 - matrix.notna().sum().sum() / (matrix.shape[0] * matrix.shape[1]) print(f"评分矩阵维度: {matrix.shape}") print(f"稀疏度: {sparsity:.4f}") print(f"平均每个用户评分数量: {matrix.notna().sum(axis=1).mean():.1f}")逻辑说明:pivot_table 默认对重复项取均值,但数据已经去重,正常不会触发聚合。稀疏度是 1 减去非空元素占比,图书推荐系统里稀疏度超过 95% 很正常,甚至 99% 也能跑,前提是用户和图书都经过一轮最小行为过滤。平均每个用户评分数量这个指标很重要,如果连 5 条都不到,ItemCF 的相似度计算会失去统计意义。
3.3 最小行为阈值过滤:用户和图书都要有“底仓”
过滤是数据清洗里决定成败的一步。每个用户至少要评过 N 本书,才有足够的“已知兴趣”去做推荐;每本书至少要被 M 个用户评过分,它的相似度向量才不是噪声。N 和 M 设多少取决于数据量:两三万条评分的课程设计数据,我常用用户 5 本、图书 10 次;百万级数据可以调到用户 10 本、图书 20 次。
# 每个用户至少评过5本书 user_size = ratings.groupby('user_id')['isbn'].nunique() valid_users = user_size[user_size >= 5].index ratings = ratings[ratings['user_id'].isin(valid_users)] # 每本书至少被10个用户评过 item_size = ratings.groupby('isbn')['user_id'].nunique() valid_items = item_size[item_size >= 10].index ratings = ratings[ratings['isbn'].isin(valid_items)] # 过滤后必须重新编码,否则category里还残留旧类,会留下空行空列 ratings['uid'] = ratings['user_id'].astype('category').cat.codes ratings['iid'] = ratings['isbn'].astype('category').cat.codes参数说明:groupby('user_id')['isbn'].nunique() 统计的是每个用户读过的不同图书数量,用 nunique 而不是 count 是为了避免同一本书的多条评分干扰。阈值这块有个血泪经验:过滤条件必须在编码之前做,或者编码之后重新 astype('category'),否则 pivot 出来的矩阵会带上大量全空的行和列,相似度矩阵里全是 0。第 5 章的避坑清单里还会专门提这一点。
4. Python实现ItemCF图书推荐:相似度矩阵、Top-N推荐与评估代码
数据矩阵就绪,进入编码主流程。项目做到这一步,我会把代码拆成三个文件,而不是堆在一个 Notebook 里:DataLoader 负责读数据、过滤、构建矩阵;Similarity 负责相似度计算;Recommender 负责推荐和评估。这样文档说明(README)也好写,答辩时逻辑也清晰。
项目结构常见的组织方式:
book_recsys/ ├── data/ │ ├── ratings.csv │ └── books.csv ├── src/ │ ├── loader.py │ ├── similarity.py │ ├── recommender.py │ └── evaluate.py ├── README.md └── requirements.txtREADME 里必须写清楚四件事:依赖怎么装、数据文件放哪、运行命令是什么、输出结果长什么样。不需要长篇大论,但要保证拿到代码的人能一字不改地跑通。requirements.txt 固定 pandas、numpy、scipy 版本就够了。
4.1 DataLoader:把数据进内存和清洗收敛到一个类里
我习惯把数据加载和过滤逻辑封成类,参数暴露在构造函数里,方便反复调阈值而不改核心代码:
import pandas as pd class DataLoader: def __init__(self, ratings_path, min_user_actions=5, min_item_actions=10): self.ratings_path = ratings_path self.min_user_actions = min_user_actions self.min_item_actions = min_item_actions self.ratings = None self.matrix = None def load(self): # 假设CSV没有表头,按顺序命名 ratings = pd.read_csv( self.ratings_path, names=['user_id', 'isbn', 'rating'] ) # 去重,同一用户对同一本书保留最后一条评分 ratings = ratings.drop_duplicates( subset=['user_id', 'isbn'], keep='last' ) # 用户维度过滤 user_size = ratings.groupby('user_id')['isbn'].nunique() valid_users = user_size[user_size >= self.min_user_actions].index ratings = ratings[ratings['user_id'].isin(valid_users)] # 物品维度过滤 item_size = ratings.groupby('isbn')['user_id'].nunique() valid_items = item_size[item_size >= self.min_item_actions].index ratings = ratings[ratings['isbn'].isin(valid_items)] self.ratings = ratings return self.ratings def build_matrix(self): # 编码后建宽表,NaN保留,不要fillna(0) self.ratings['uid'] = self.ratings['user_id'].astype('category').cat.codes self.ratings['iid'] = self.ratings['isbn'].astype('category').cat.codes self.matrix = self.ratings.pivot_table( index='uid', columns='iid', values='rating' ) return self.matrix参数说明:min_user_actions=5 和 min_item_actions=10 是过滤阈值,课程设计数据量小可以分别降到 3 和 5。build_matrix 里把编码和 pivot 放一起,保证过滤后不会再出现旧类别的空列。运行时只需要两行:dl = DataLoader('data/ratings.csv'); mat = dl.load() 然后 mat = dl.build_matrix()。
4.2 相似度矩阵的向量化计算:别写双重循环
初学者最容易写出的版本是两层 for 循环遍历所有物品对,每对调一次 pearson_sim,时间复杂度 O(n² × m),图书量一上万,跑一夜都是常态。我一般用矩阵运算来做:把物品的评分向量当成矩阵的列,一次算出所有两两相似度。
import numpy as np import pandas as pd def build_sim_matrix(matrix, min_common=5, sim_type='pearson'): # matrix: 行=用户, 列=物品, 值为评分或NaN if sim_type == 'cosine': # 填充0后的矩阵乘法,等价于余弦相似度的分子 mat = matrix.fillna(0).values norm = np.linalg.norm(mat, axis=0) sim = mat.T @ mat / np.outer(norm, norm) else: # 皮尔逊:先对每个用户中心化,再算余弦 centered = matrix.sub(matrix.mean(axis=1), axis=0).fillna(0).values norm = np.linalg.norm(centered, axis=0) sim = centered.T @ centered / np.outer(norm, norm) sim = pd.DataFrame(sim, index=matrix.columns, columns=matrix.columns) np.fill_diagonal(sim.values, 0) # 物品与自己的相似度置0 # 共评数过滤:两本书共同评过的用户太少,相似度不可信 co_rated = matrix.notna().values.astype(int) co_count = co_rated.T @ co_rated sim[co_count < min_common] = 0 return sim逻辑说明:mat.T @ mat 得到的是所有物品两两之间的评分点积矩阵,除以模长外积就是余弦相似度。皮尔逊版本先 matrix.sub(matrix.mean(axis=1), axis=0) 把每行中心化,再做同样的矩阵乘法。np.fill_diagonal 把对角线的 1.0 或 0.0 清掉,防止推荐时把自己算进去。co_count 是物品间的共同评分用户数,低于 min_common 的全部置 0,这是防止伪相关的关键开关。
参数说明里要提醒一个边界:这个向量化写法把 NaN 填成 0 后才做矩阵乘法,相当于默认“未评分”对点积没有贡献,但在模长归一化时,未评分位置的 0 会拉低该物品的有效模长,导致相似度略微偏高。数据越稀疏,这种偏差越明显。如果介意,就退回逐对计算,但需要接受 O(n²) 的时间。课程设计规模下,两者结果差异通常不影响 Top-N 排序,所以大多数项目直接采用向量化版本。
4.3 推荐函数:加权平均之后排除已读书籍
推荐的核心函数输入是某个用户的评分行,输出是 Top-N 的图书 ID。设计成接收一个 Series,这样既可以从矩阵里取行调用,也可以在评估时自由构造“去掉一本测试书”的评分行。
def recommend(user_ratings, sim_matrix, top_n=10, k=20): # user_ratings: Series, index=物品ID, value=评分 scores = pd.Series(0.0, index=sim_matrix.index) weights = pd.Series(0.0, index=sim_matrix.index) for item, rating in user_ratings.items(): if pd.isna(rating): continue # 取与当前物品最相似的k本书,只保留正相似度 sims = sim_matrix[item].drop(index=item, errors='ignore') sims = sims.sort_values(ascending=False).head(k) sims = sims[sims > 0] scores[sims.index] += sims * rating weights[sims.index] += sims.abs() # 归一化得到预测分,未参与加权的物品保持NaN pred = scores / weights.replace(0, np.nan) # 排除用户已经读过的书 pred = pred.drop(index=user_ratings.dropna().index, errors='ignore') return pred.sort_values(ascending=False).head(top_n).index.tolist()参数说明:k=20 是每个已读书籍最多带出的相似书数量,数值越大召回越高但噪声也越多;top_n=10 是最终返回的推荐条数。drop(index=item) 排除书籍自身,sort_values 降序排序后取前 k 个,保证参与加权的都是真正相似的邻居。weights 累加的是相似度绝对值,这样预测值可以解释成“加权平均分”,不会因为某个相似度很大的单本书把分数抬得过猛。
4.4 离线评估:留一法算 Hit Rate
推荐系统不能只看代码跑通,要有量化指标证明推荐有效。课程设计常用的评估是留一法:对每个至少有两本评分书的用户,随机藏起一本当测试样本,用剩余数据重新生成推荐列表,如果 Top-N 包含了藏起的那本书就算命中。命中率 = 命中用户数 / 测试用户数。
def predict_from_row(user_ratings, sim_matrix, top_n=10, k=20): # 上层函数:评价时传入去掉测试书之后的评分行 return recommend(user_ratings, sim_matrix, top_n=top_n, k=k) def evaluate_hit_rate(matrix, sim_matrix, top_n=10, test_ratio=0.2, seed=42): rng = np.random.default_rng(seed) test_cases = [] for uid in matrix.index: rated = matrix.loc[uid].dropna() if len(rated) < 2: continue hidden = rng.integers(0, len(rated)) test_cases.append((uid, rated.index[hidden])) hits = 0 for uid, hidden in test_cases: train_row = matrix.loc[uid].copy() train_row[hidden] = np.nan # 藏起测试书 pred_list = predict_from_row(train_row, sim_matrix, top_n=top_n) if hidden in pred_list: hits += 1 return hits / len(test_cases)参数说明:test_ratio 在留一法里常用于决定从每个用户评分的尾部取多少比例做测试,我这里直接用随机抽一本。seed=42 固定随机种子,保证评估结果可复现,这在文档说明里会被反复核对。Hit Rate 数值受 top_n 影响很大,top_n=10 时 20% 以上就算不错,top_n=5 时能到 15% 已经是有效推荐。评估指标不需要追求论文级的数字,但要有对比:调大 k、改小 min_common、换成余弦相似度,分别看 Hit Rate 怎么变化,这组对比实验是文档说明里最出彩的部分。
5. 图书推荐系统避坑指南:5个常见问题与解决办法
ItemCF 代码虽短,落地踩坑却不少。这一章把最常见的 5 个问题按“现象 → 原因 → 解决”写透,每一条都是实际项目里会真实遇到的。
5.1 冷启动:新用户或新书进来,推荐结果直接为空
现象:某个用户只在系统里注册过、没评过分,调用 recommend 时 scores 全是 0,推荐列表返回空数组;同理,新上架的书没有历史评分,永远不会进入推荐池。
原因:ItemCF 完全依赖用户行为数据,没有行为就没有相似度,这是协同过滤的先天缺陷,任何调参都改变不了“没数据就推不出”的事实。
解决:工程上做两级兜底。用户维度,没有任何评分时直接返回全局热门书榜,代码上就是按矩阵列的非空评分数量排序取 top_n;物品维度,新书用内容特征做冷启动,比如按作者、出版社、分类标签找同类书。推荐结果里把“热门榜”和“个性化推荐”分成两个栏目,既能保住体验,也诚实说明了数据不足。
def recommend_for_user(uid, matrix, sim_matrix, top_n=10, k=20): if uid not in matrix.index: # 冷启动兜底:返回被最多用户评分的书 hot = matrix.notna().sum().sort_values(ascending=False).head(top_n) return hot.index.tolist() user_ratings = matrix.loc[uid].dropna() return recommend(user_ratings, sim_matrix, top_n=top_n, k=k)5.2 哈利·波特陷阱:热门图书霸占推荐列表
现象:不管用户读的是《三体》还是《小王子》,推荐列表里永远有几本大众畅销书,比如《哈利·波特》系列。单独看每本书的相似度没问题,但汇总后热门书的加权分数永远最高。
原因:热门书和大量书籍有过共现,点积的绝对值天然更大。加权求和公式没有对物品热度做惩罚,热书在每一项里都能“掺一脚”,累计分数自然把长尾书挤出榜单。
解决:对相似度做热度惩罚。设定 item_degree 为每本书的评分用户数,相似度除以它的 log 值,压缩热门书的影响力。
def build_sim_matrix_with_penalty(matrix, min_common=5, penalty=True): sim = build_sim_matrix(matrix, min_common=min_common, sim_type='pearson') if penalty: degree = matrix.notna().sum(axis=0) # 每本书的评分人数 penalty_factor = np.log(degree + 1) sim = sim.div(penalty_factor, axis=0) sim = sim.div(penalty_factor, axis=1) return sim参数说明:degree + 1 是防 log(0),两轴都除是为了保持对称性。penalty 会让冷门书之间的相似度相对上升,推荐列表的长尾比例明显提高。注意惩罚系数不能过大,否则推荐会走向另一个极端——全是随机冷门书。
5.3 皮尔逊伪相关:只有两三本共同评分就给出±1.0
现象:相似度矩阵里出现大量接近 1.0 或 -1.0 的数值,抽查几对书,发现它们共同被评分的用户只有两三个人。
原因:皮尔逊相关系数在小样本下没有统计意义。两个评分向量在中心化之后一旦方向一致,不管用户数多少都会算出 ±1。
解决:min_common 过滤阈值不能省。第 4 章的 build_sim_matrix 里已经有 co_count < min_common 置 0 的逻辑,实践里 min_common 至少 5,数据量大时建议 10 或 20。吃亏过的朋友可以打印几对“高分相似书”的实际共评数,看到只有 2 时基本就能定位问题。
5.4 ISBN分裂:同一本书换了版本就被当成两本书
现象:推荐结果里出现同一个书名、不同 ISBN 的多个版本,或者本来应该相似度极高的同一本书,因为 ISBN 不同导致相似度偏低。
原因:图书数据中平装版、精装版、影印版、再版各有独立 ISBN。评分表用 ISBN 标识图书,导致同一作品被拆成多个物品,共现次数被稀释。
解决:清洗阶段尽量用“书名+作者”聚合。如果数据集有 books.csv,先按书名和作者去重,保留出现次数最多的 ISBN 作为主标识;评分表里的其他 ISBN 映射到主标识上。聚合之后物品数会下降一到两成,但相似度质量会明显提升。
# books.csv: isbn, title, author books = pd.read_csv('books.csv') books['key'] = books['title'].str.lower() + '|' + books['author'].str.lower() # 每个key保留被评分数最多的ISBN作为主ISBN isbn_count = ratings.groupby('isbn')['user_id'].nunique() books['cnt'] = books['isbn'].map(isbn_count).fillna(0) main_isbn = books.sort_values('cnt', ascending=False).groupby('key')['isbn'].first().rename('main_isbn') ratings = ratings.merge(main_isbn, left_on='isbn', right_index=True) ratings['isbn'] = ratings['main_isbn'] ratings = ratings.drop(columns='main_isbn')5.5 内存爆炸:数字一上百万,相似度矩阵直接 OOM
现象:评分数据几十万条时还能跑,到百万级时 build_sim_matrix 在 np.outer 附近直接报 MemoryError,或者跑几分钟后进程被杀。
原因:物品×物品的稠密矩阵空间复杂度是 O(n²)。一万本书就是 1 亿个浮点数,占 800MB;十万本书直接 80GB,任何笔记本都扛不住。
解决:不要保留完整相似度矩阵,每个物品只保留 Top-50 相似邻居,用 scipy 的稀疏矩阵存储。在线推荐只需要查每行的非零邻居,稀疏矩阵完全够用。
from scipy.sparse import csr_matrix def topn_sparse_sim(sim_df, keep_n=50): # 对每一行只保留相似度最高的keep_n个邻居 row, col, data = [], [], [] for i, idx in enumerate(sim_df.index): top = sim_df.iloc[i].sort_values(ascending=False).head(keep_n) for col_idx, val in top.items(): if val > 0: row.append(i) col.append(sim_df.columns.get_loc(col_idx)) data.append(val) return csr_matrix((data, (row, col)), shape=sim_df.shape)参数说明:keep_n=50 实际使用中足够支撑 k=20 的推荐逻辑,因为每本书只需要前 50 个相似邻居就已经涵盖绝大多数有效信息。注意先把相似度矩阵的对角线置 0、负值清零,再转稀疏,否则会白白占用存储。
6. 离线评估与可解释推荐:验证推荐器真的有效
推荐系统交付之前,至少要回答三个问题:推荐结果准不准、榜单有没有多样性、能不能解释给用户听。Hit Rate 只能回答第一个,实际项目里我会再补两个指标:覆盖率(推荐列表里有多少不同图书被推出去)和平均推荐列表相似度(防止推的书全是同一作者的同一风格)。覆盖率等于被推荐图中的去重书数除以全部图书数,太低说明系统只会炒热门;平均推荐列表相似度则是把每次推荐里的书两两算相似度取平均,太高说明结果太窄。
可解释性是 ItemCF 比 UserCF 更容易落地的点,也是答辩时最能讲出深度的部分。实现方式不复杂:recommend 函数里把每本书的相似邻居和对应权重一起返回,生成推荐理由时可以这样组织:
reason = f"因为您读过《{seed_title}》,这本书与《{rec_title}》的相似度为{sim_value:.2f}"这样做的价值是双重的:用户层面,“因为您读过 A”比“系统为您推荐”可信得多;开发层面,每条推荐都能溯源到具体哪本书贡献了预测分,出问题容易排查。数据量允许的话,还可以把推荐理由放到离线日志里统计:哪类书贡献的推荐被点击率高,下次调整相似度权重就有了依据。
我的习惯是准备一个 experiments.csv,记录每次调参后的 top_n、k、min_common、Hit Rate 和覆盖率。无论指标是涨是跌,都能证明这次改动不是玄学,而是有数据支撑的决策过程。希望帮到你。
本文还有配套的精品资源,点击获取