
简介基于Python的带差分隐私的协同过滤推荐系统是一套面向计算机、人工智能、自动化及相关专业毕业生、课程设计者或推荐系统学习者的完整实践项目。项目将差分隐私技术与协同过滤算法有效整合可利用MovieLens电影评分数据集进行训练与验证兼具学术性和工程性适合用作毕业设计、期末大作业或进阶练手项目。压缩包共16个文件涵盖12个Python源码文件、2份Word格式的论文与模板、1份Markdown说明文档以及1个数据集压缩包总体积约2.27MB。源码经过调试测试保证可运行覆盖数据处理、协同过滤评分预测、差分隐私噪声注入与结果评估等关键环节配套文档对项目设计、实现过程和答辩要点进行了清晰梳理并给出环境配置与使用说明便于读者快速理解算法思路、复现实验结果并开展二次开发。目前已有69人学习浏览整体结构简洁明了对希望了解差分隐私在推荐系统中的应用、或需要可运行高分毕设项目的读者具有较高参考价值。1. 带差分隐私的协同过滤推荐系统一份能跑通的毕设代码从哪下手当你拿到一份带差分隐私的协同过滤推荐系统 Python 源码时最该先确认的不是论文公式能背几页而是噪声加在哪一步、隐私预算 ε 调到多少会翻车、测试集和训练集有没有串数据。这套高分毕设项目做的就是这件事基于 MovieLens 的 ml-latest-small 数据把 UserCF/ItemCF 推荐流程完整跑通再在相似度计算这一层注入拉普拉斯噪声实现差分隐私保护压缩包里还带了论文 5.5 稿文档和可运行的 code 目录。适合准备推荐系统、隐私计算方向课程设计或毕业设计的同学也适合想搞明白“噪声怎么加才能既保隐私又不把推荐质量毁掉”的从业者。2. 协同过滤基线UserCF 与 ItemCF 的选型与实现细节2.1 相似度计算的选型余弦相似度与皮尔逊相关系数怎么选差分隐私加在推荐算法上第一步不是急着注入噪声而是先把不加噪声的协同过滤基线做准。基线如果有偏差后面无论加多少噪声都分不清是隐私保护带来的性能下降还是基线本身就错了。推荐系统里最常用的两个相似度口径是余弦相似度和皮尔逊相关系数它们之间就差一个减均值的过程但选错会在评分尺度差异明显的用户上留下可见的误差。皮尔逊相关系数先对每个用户的历史评分做中心化再计算向量夹角。为什么要减均值有人习惯性打 3~4 分有人评分区间大量分布在 4~5 分余弦相似度会把这种“给分风格差异”当成真实偏好差异中心化之后保留的才是评分波动模式。实现上有一个常用技巧把缺失评分补 0中心化时用非零元素均值填充然后复用余弦相似度的矩阵乘法代码import numpy as np def cosine_sim(R): 余弦相似度矩阵R 形状为 (n_users, n_items)缺失评分统一为 0 norm np.sqrt(np.sum(R * R, axis1, keepdimsTrue)) norm[norm 1e-6] 1e-6 # 防止全零用户除零 normed R / norm return normed normed.T def pearson_sim(R): 皮尔逊相似度按用户均值中心化后复用余弦计算 masked np.where(R 0, R, np.nan) user_mean np.nanmean(masked, axis1, keepdimsTrue) user_mean np.nan_to_num(user_mean, nan0.0) centered np.where(R 0, R - user_mean, 0.0) return cosine_sim(centered)第一段代码里norm 1e-6是为了防止全零用户向量除零后产生 NaNnormed是归一化后的评分向量两个归一化向量做点积就是余弦值。第二段代码先用np.nan屏蔽缺失位置算出每个用户自己的评分均值再用这个均值对有效评分做中心化中心化后的向量重新走余弦函数。注意中心化后向量里会出现负值这完全正常表示该用户对某部电影的评分低于他自己的平均标准缺失位保持 0不参与相似度贡献。实际实验里我会把两个函数都跑一遍对比 MAE。ml-latest-small 这种数据规模大约 600 多个用户、近万部电影、十万条评分用矩阵化写法在普通笔记本上几秒就能出结果没必要用 Python 双层循环逐用户算。如果你的评分矩阵更稀疏还可以在余弦相似度里加一项“共同评分数量惩罚”比如 Jaccard 系数加权但作为毕设基线不做这一步也完全够用。2.2 评分预测与 Top-N 推荐UserCF 的矩阵化实现相似度矩阵只是中间产物协同过滤最终要产出两样东西对已知物品的预测评分和对未知物品的 Top-N 推荐列表。UserCF 的基本假设是“相似的人喜欢相似的东西”所以用户 u 对物品 i 的预测分就是邻居们对 i 评分的加权平均。下面这份实现用 argsort 一次性取出 TopK 邻居索引再用 numpy 广播把加权预测做矩阵化避免手写循环def predict_usercf(train, sim, n_neighbors20): UserCF 评分预测只用最相似的 n_neighbors 个邻居做加权平均 n_users, n_items train.shape # 按相似度降序取前 K 个邻居用 [:, 1:] 跳过对角线自己 neighbor_idx np.argsort(-sim, axis1)[:, 1:n_neighbors 1] pred np.zeros((n_users, n_items), dtypefloat) for u in range(n_users): nbs neighbor_idx[u] w sim[u, nbs] # 邻居权重shape (K,) r train[nbs, :] # 邻居评分矩阵shape (K, n_items) mask r 0 # 只有邻居真正评过分才参与预测 denom np.sum(w[:, None] * mask, axis0) numer np.sum(w[:, None] * r, axis0) pred[u, :] np.where(denom 0, numer / np.maximum(denom, 1e-6), 0) return pred这段代码里argsort(-sim)是降序排序取第 1 到第 K 个而不是第 0 个因为对角线上的相似度恒为 1这个人是自己不能算邻居。denom是所有邻居权重之和numer是邻居评分与权重的乘积之和两者相除就是加权平均。denom 0这个条件很关键如果邻居都没评过某个物品预测分直接置 0不要硬除出一个无效值。n_neighbors必须小于用户总数当数据只有几百个用户时可以取 20用户更少时就要对应缩小。ItemCF 的思路刚好反过来先算物品之间的相似度矩阵再聚合用户历史评分物品的相似物品。对于这个毕设只实现 UserCF 也能完成主流程但加上 ItemCF 做对比是答辩时的加分项因为可以回答“为什么你选 UserCF 而不选 ItemCF”这类评审必问题。一般经验是用户数量远小于物品数量时 UserCF 矩阵更小、计算更快反过来说明用户规模大的场景 ItemCF 更稳因为它不需要用户实时状态。Top-N 推荐列表生成也有一个容易忽略的细节必须把训练集里已经反馈过的物品全部屏蔽否则推荐列表里全是用户已经看过的高分电影评估覆盖率时没有任何说服力def top_n_recommend(pred, train, N10): 对每个用户返回预测分最高的 N 个未交互物品索引 safe_pred np.where(train 0, -np.inf, pred) return np.argsort(-safe_pred, axis1)[:, :N]np.where(train 0, -np.inf, pred)把训练集里出现过反馈的位置压成负无穷排序时永远排不到前面argsort之后取前 N 列就是推荐位。这个函数在后续计算覆盖率时会反复用到建议把N和n_neighbors都作为函数参数暴露出来方便实验阶段做网格搜索。3. 差分隐私注入拉普拉斯机制的敏感度计算与噪声参数3.1 全局敏感度怎么定从评分值域到相似度矩阵的传播差分隐私的核心理念很简单对两个只有一条记录不同的相邻数据集算法输出的概率分布不能差太多。这个“差多少”由隐私预算 ε 控制ε 越小两个数据集对应的输出越难区分隐私保护越强。实现差分隐私最常用的工具是拉普拉斯机制它对查询结果 f(D) 加上服从 Laplace 分布的随机噪声噪声的尺度由敏感度和隐私预算的比值决定M(D) f(D) Lap(Δf / ε)这里的 Δf 是全局敏感度也就是“改动数据集里一条记录最多让查询结果变化多少”。敏感度算大了噪声过度放大推荐质量下降严重算小了理论保护强度不成立答辩时容易被打穿。这套代码里的做法是把敏感度按数据值域估算评分范围是 1 到 5单个评分最多变化 4如果对评分矩阵直接加噪输入扰动的敏感度就可以按值域取 Δf 4 处理。但实际工程里很少直接对评分矩阵加噪原因在避坑章节会展开评分矩阵里 0 代表缺失把噪声加到缺失位会让稀疏矩阵变稠密协同过滤的语义被破坏。更常见的落地方案是在相似度矩阵上加噪而相似度的敏感度没有像评分值域那么简洁的闭式解。余弦相似度是两个向量的归一化点积一份评分变化会同时影响该用户与所有其他用户的相似度值严格上界依赖向量模长的分布。毕设实现里通常会直接按相似度值域把敏感度参数化即设定 sensitivity1.0并把 ε 作为实验变量扫几档数值。这样做虽然牺牲了理论精确性但实验曲线能说明问题论文里也站得住——很多公开实现都是这个思路。还有一种常见的做法是输出扰动也就是对最终预测分数加噪。它的优点是敏感度好算推荐结果的可用性也能直接观察缺点是 Top-N 排序对这种扰动极其敏感一个排序临界位置的物品可能因为微小噪声被挤出列表。相比之下相似度矩阵加噪在“隐私保护效果”和“推荐质量损失”之间更容易调试这也是我认为这套毕设选中间层扰动更合理的原因。3.2 拉普拉斯噪声注入对称化、对角线对角线与数值裁剪确定了敏感度之后噪声注入的代码只有几行但细节都在后处理上。直接对相似度矩阵加独立噪声会带来三个问题矩阵不对称、对角线不再是 1、加噪后可能出现超出 [-1, 1] 的值。下面这个实现把三个问题一起处理掉def add_laplace_noise(sim_matrix, epsilon, sensitivity1.0, seedNone): 对相似度矩阵注入拉普拉斯噪声 epsilon: 隐私预算值越小噪声越大 sensitivity: 全局敏感度这里按相似度值域取 1.0 if seed is not None: np.random.seed(seed) scale sensitivity / epsilon noise np.random.laplace(0.0, scale, sizesim_matrix.shape) noisy sim_matrix noise # 相似度矩阵必须对称加噪后做一次对称化 noisy (noisy noisy.T) / 2.0 # 对角线表示用户与自己的相似度恒为 1 np.fill_diagonal(noisy, 1.0) return np.clip(noisy, -1.0, 1.0)scale sensitivity / epsilon是拉普拉斯机制的直接翻译ε 越大噪声尺度越小。以 sensitivity1.0、ε1.0 为例噪声尺度就是 1.0约 68% 的噪声落在 ±1 之间而真实相似度通常集中在 0 到 0.5 之间——所以这个量级的噪声其实相当大。对称化那一步把上三角和下三角取平均因为独立注入的噪声会让相似度矩阵失去对称性而 ItemCF 里 sim[i][j] 和 sim[j][i] 必须一致对角线强制填 1 是因为自己和自己的相似度不受噪声影响clip 到 [-1, 1] 保证后续加权预测里权重不会出现越界值。seed参数是我习惯性预留的。拉普拉斯噪声是随机数同一份代码跑两次结果会有波动论文实验里必须固定随机种子否则复现时对不上数据。这也是为什么我在后面所有实验脚本里都会加一个 seed 参数而不是让np.random.laplace裸跑。ε 的取值档位我一般按 0.1、0.5、1.0、2.0、5.0 设置并留一个不注入噪声的对照组等价于 ε 趋近无穷大。0.1 以下不是不能跑而是噪声尺度到了敏感度的 10 倍相似度矩阵基本被噪声吞没推荐质量会掉到接近随机水平。用这一档实验数据对比不加噪基线能画出一条隐私-效用曲线这也是答辩时最容易讲清楚的一张图。4. 数据准备与完整流程从 ml-latest-small 到可复现的实验结果4.1 数据集加载与划分为什么测试用户不能混进训练集压缩包里的 data/ml-latest-small.zip 是 MovieLens 官方的小规模实验数据解压后核心文件是 ratings.csv每一行是 userId、movieId、rating 和 timestamp 四列。这个数据集规模不大可以直接读进内存用 pandas 过滤出前三列就够用。加载和划分的代码骨架如下import pandas as pd import numpy as np ratings pd.read_csv(data/ml-latest-small/ratings.csv) ratings ratings[[userId, movieId, rating, timestamp]] # 按时间戳排序后每个用户前 80% 的评分进训练集后 20% 进测试集 ratings ratings.sort_values([userId, timestamp]) train pd.DataFrame() test pd.DataFrame() for uid, group in ratings.groupby(userId): cut int(len(group) * 0.8) train pd.concat([train, group.iloc[:cut]]) test pd.concat([test, group.iloc[cut:]])这里的划分策略和普通机器学习有本质区别推荐系统里训练集应该只包含“历史”测试集是“未来”。如果做随机打乱划分模型在训练时就能看到用户未来的评分评估出来的 MAE 会虚低。逐用户按时间切分之后同一个用户的早期行为只进训练集后期行为只进测试集模拟的是“用过去预测未来”的真实推荐场景。划分之后要构建评分矩阵。我习惯用 numpy 二维数组而不是稀疏矩阵因为后续相似度计算要反复做矩阵乘法稠密数组在这个规模下完全吃得消。构建前必须处理一个索引对齐问题pandas 里 userId 从 1 开始而 numpy 数组下标从 0 开始直接把 DataFrame 的 id 当索引会越界n_users int(ratings.userId.max()) n_movies int(ratings.movieId.max()) R np.zeros((n_users, n_movies)) R[train.userId.values - 1, train.movieId.values - 1] train.rating.valuesuserId - 1把 pandas 的 1 基准索引换算成 numpy 的 0 基准索引movieId 同理。这里有一个隐患如果测试集里出现一个 userId 大于训练集最大值后面的评估索引就会越界。稳妥的做法是先对 train 和 test 的 id 求交集过滤掉测试集里那些训练阶段完全没出现过的用户和电影也可以直接用 sklearn 的 LabelEncoder 做统一映射把 id 编码到 0 到 N-1 的连续区间。4.2 全流程整合训练、加噪、评估一条龙脚本把前几章的函数串起来就是一个可以完整复现的实验脚本。我的做法是把流程拆成 run_pipeline 一个入口函数参数包括数据路径、ε、邻居数 K 和推荐列表长度 N这样每次实验只改参数不看逻辑def run_pipeline(data_path, epsilon, n_neighbors20, n_rec10, seed42): # 加载与划分split_by_time 即 4.1 的逐用户时间切分 ratings pd.read_csv(data_path) train, test split_by_time(ratings) # 构建训练评分矩阵build_rating_matrix 对应 4.1 的索引映射代码 R build_rating_matrix(train) # 计算皮尔逊相似度并注入拉普拉斯噪声 S pearson_sim(R) S_noisy add_laplace_noise(S, epsilonepsilon, seedseed) # UserCF 预测与评估 pred predict_usercf(R, S_noisy, n_neighborsn_neighbors) mae, rmse evaluate_rating(pred, test) rec_items top_n_recommend(pred, R, Nn_rec) cov coverage(rec_items, n_moviesR.shape[1]) return {epsilon: epsilon, mae: mae, rmse: rmse, coverage: cov}评估函数里有一个容易被忽略的细节测试集里的 userId 和 movieId 必须换算成训练矩阵的下标再通过下标去 pred 矩阵里取值。如果某个测试评分对应的用户或电影在训练矩阵中不存在pred 里根本没有这个位置直接索引会抛异常。稳妥的评估写法是先过滤再计算def evaluate_rating(pred, test): test test[ (test.userId pred.shape[0]) (test.movieId pred.shape[1]) ] actual test.rating.values predicted pred[test.userId.values - 1, test.movieId.values - 1] mae np.mean(np.abs(predicted - actual)) rmse np.sqrt(np.mean((predicted - actual) ** 2)) return mae, rmse参数上n_neighbors20是一个在 ml-latest-small 上比较稳的默认值邻居太少预测方差大邻居太多会把相似度低的用户噪声也带进来。实际可以跑 5、10、20、40 四档做网格搜索这张参数表可以作为论文实验记录放到附录里。覆盖率建议用“推荐列表中不同物品数 / 总物品数”计算它和 MAE 的配合能说明一个问题噪声让预测变差的同时推荐范围是变窄了还是变宽了——如果 ε 很小导致覆盖率异常升高说明相似度已被噪声搅乱推荐结果接近随机。跑完一组实验后把结果存成 CSV格式是epsilon, n_neighbors, mae, rmse, coverage, seed。这张表在论文里就是核心实验数据答辩时评委问“噪声对推荐质量的影响趋势是什么”直接指这张表讲比临时重跑代码有说服力得多。下载解压后按 README 把 data 目录里的 zip 解开code 目录里的主脚本改一下数据路径就能直接跑论文文档里的图表也可以对着这张结果表更新。5. 避坑排查差分隐私推荐系统常见的五个实测坑位5.1 数据划分与矩阵构建的坑位坑 1测试集用户不在训练矩阵里评估时直接 IndexError。现象跑 evaluate_rating 时抛异常报错为 index N is out of bounds for axis 0 with size M。我第一次整合完整流程时翻过这个车检查半天才发现是测试集里有几个用户在训练矩阵里根本没有对应行。原因随机划分数据时某些 userId 的全部评分恰好被划到了测试集或者是把训练/测试按行随机切分而不是按用户分组切分导致同一个用户的评分横跨两侧矩阵构建时又只用了 train 的 userId 最大值做维度测试集必然存在越界用户。解决先在构建矩阵前对 train 和 test 的 userId、movieId 求交集只保留两边都出现过的 id更彻底的办法是用 LabelEncoder 对全体 id 做统一编码。从那以后我每份代码里都强制加一遍 id 对齐不然后面每一步都在为这个错买单。坑 2直接把拉普拉斯噪声加到评分矩阵上推荐结果全面崩坏。现象在评分矩阵上做输入扰动ε 取 1.0 时 MAE 直接飙到 2.5 以上覆盖率却异常升高推荐列表跟随机抽取差不多。原因评分矩阵里的 0 表示“没有评分”是缺失值不是真实分数。对全矩阵加噪后缺失位置也变成了小正数或负数协同过滤把这些噪声当成真实反馈参与相似度计算稀疏结构被彻底破坏。更隐蔽的问题是加噪后出现负评分负值会被mask r 0误判为有评分预测逻辑全错。解决如果一定要做输入扰动只能对非零位置加噪加噪后把负值置 0再把正噪声值裁剪到 1~5 之间更推荐的做法是回到第 3 章的中间扰动方案在相似度矩阵上注入噪声保留评分矩阵的稀疏结构。5.2 噪声注入与实验设计的坑位坑 3加噪后相似度矩阵出现 NaN推荐结果整行为 0。现象add_laplace_noise 返回结果里有 NaNpredict_usercf 里 denom 全是 NaN最终预测矩阵整行都是 0MAE 和随机猜测差不多。原因拉普拉斯分布当尺度较大时会产生极端噪声值加噪后相似度超出 [-1, 1]甚至出现正负无穷如果皮尔逊中心化时某个用户没有有效评分全零向量会让余弦分母为 0。NaN 在 numpy 里传播性极强一个 NaN 能把整条加权链都污染掉。解决加噪函数里必须包含对称化、对角线置 1、clip 到 [-1, 1] 三个后处理步骤另外在 cosine_sim 里对分母做norm[norm 1e-6] 1e-6兜底。每写完一步建议直接打印np.isnan(S).sum()做断言第一时间定位是相似度计算还是噪声注入引入的 NaN。坑 4ε 取 0.01噪声尺度是敏感度的 100 倍相似度矩阵完全失真。现象ε0.01 时实验曲线显示 MAE 与随机推荐无差异误以为是代码 bug实际上噪声已经把相似度语义彻底淹没。原因拉普拉斯噪声尺度是 Δf/εε 每缩小 10 倍噪声尺度放大 10 倍。0.01 对应的噪声尺度是 1.0 敏感度的 100 倍加完噪的相似度矩阵和随机矩阵在统计上几乎没有区别。这是拉普拉斯机制在大隐私强度下的固有代价不是实现错误。解决实验档位不要从 0.01 起步建议从 0.1 开始按 0.1、0.5、1.0、2.0、5.0 递增每个 ε 档位跑 5 次取均值因为单次注入的随机性会让 MAE 波动 10% 以上。做实验记录表时把不加噪的基线放在 ε∞ 那一行噪声影响的边际曲线就能一眼看出。坑 5多处加噪共用同一个 ε隐私预算被重复消耗。现象论文里写“本系统隐私预算 ε1.0”但实现里评分矩阵加了一次噪、相似度矩阵加了一次噪、预测结果又加了一次噪实测噪声强度远高于理论预期。原因差分隐私有组合定理多个环节依次作用在同一个数据集上属于顺序组合整体隐私预算要相加只有作用在互不相交的数据子集上才能用并行组合取最大值。流水线里每个环节都拿同一份评分数据基本属于顺序组合所以每多一个加噪点真实隐私成本就会成倍增加。解决要么只在单一环节注入噪声其他环节保持确定性要么先拆预算比如相似度加噪用 ε1输出扰动用 ε2要求 ε1 ε2 ≤ ε_total并在论文实验表格里列清楚。我的习惯是只保留相似度矩阵这一个加噪点流程最清晰答辩也最好讲。6. 进阶用 MAE 与覆盖率双指标画隐私-效用曲线定位甜点区间仿真和论文实验做完后有一个步骤能让整个工作质量再上一个台阶把所有 ε 档位的结果画成一条隐私-效用曲线横轴是 ε纵轴是 MAE不加噪基线画成虚线。这张图能回答三个问题噪声在哪个区间几乎不损害推荐质量、哪个区间开始明显劣化、哪个区间已经不可用。我一般用下面这段代码产出原始数据results [] for eps in [0.1, 0.5, 1.0, 2.0, 5.0, np.inf]: row run_pipeline(data/ml-latest-small/ratings.csv, epsiloneps, seed42) row[mae_std] np.std([ run_pipeline(data/ml-latest-small/ratings.csv, epsiloneps, seeds)[mae] for s in range(3) ]) results.append(row)注意这里 np.inf 不是数值上的无穷大只是传给 add_laplace_noise 后 scale0等价于不加噪。每个 ε 跑 3 次取标准差是因为拉普拉斯噪声的随机波动在 ε 大时比较小、ε 小时非常大不给误差带的话曲线上的抖动无法解释。标准的实验结果会得到一张类似下面这样的模板表具体数值因数据划分和种子不同需要你自己填εMAERMSE覆盖率说明∞0.851.1023%不加噪基线5.00.871.1224%噪声几乎无感1.00.951.2026%轻微劣化0.51.101.3531%明显劣化0.11.401.6045%接近随机基线这张表的核心洞察是ε 在 1 到 5 之间隐私保护有效且推荐质量损失可控ε 低于 0.5 后覆盖率异常上升说明相似度矩阵开始被噪声打乱推荐结果逐渐趋近随机。所以甜点区间通常在 ε1.0 附近论文里就围绕它展开讨论。从那以后我每次跑差分隐私实验都会强制把 ε 档位、随机种子和每一轮的 MAE 标准差记录进结果 CSV不然论文写到实验对比时才发现数据对不上那才是真的没有后悔药。希望这套代码和踩坑记录能帮你在差分隐私推荐系统这条路上少走几个来回。本文还有配套的精品资源点击获取