C++从零实现协同过滤推荐系统:核心算法与工程实践
简介:这份文档面向希望入门推荐系统的开发者与计算机专业学生,以电影推荐为实战场景,讲解如何从零搭建一个可运行的推荐系统。内容围绕推荐系统的基本概念与两类主流思路展开,即基于人的推荐与基于物品的推荐,并系统梳理欧几里德距离、皮尔逊相关系数、Tanimoto系数等相似度计算算法的取值特点与适用条件,同时说明加权平均打分如何规避无效评价与偏执评分带来的干扰。资源包共1个docx文件,约312KB,以图文笔记形式组织,涵盖数据集说明、核心推荐函数GetRecommendation的接口设计与实现逻辑,以及基于6040名用户、3952部电影、1000209条评分数据的完整实验流程,便于读者对照理解算法到代码的落地过程。目前已有1966人学习,适合作为推荐系统入门与课程实践的参考材料。
1. 从一张评分表到一份片单:这个电影推荐系统到底在算什么
手里有一张 60 万行的用户评分表,每行是「谁、给哪部电影、打了几分」,现在要回答一个问题:第 1000 号用户还没看过的片子里,哪 20 部最该推给他。这个项目干的就是这件事,用 C++ 从零实现一个基于用户的协同过滤推荐系统,不依赖任何机器学习框架,核心只有一个函数指针加一套相似度算法。它适合两类人:想搞懂推荐系统底层到底在算什么、而不是直接调库的开发者;以及手里有一批用户行为数据、想先跑通一个可解释基线再谈深度模型的人。整套代码的骨架就是「算人与人之间的相似度,再用相似度加权别人的评分,最后排序取 TopN」,朴素,但每一步都能拆开看。
2. 工程骨架与数据流:从评分矩阵到推荐列表
2.1 目录与模块划分
这个项目没有复杂的工程结构,按职责拆成四块就够了,我一般会这样组织:
movie-recommender/ ├── data/ │ └── ratings.dat # 原始评分数据,格式 user::movie::score::timestamp ├── include/ │ ├── similarity.h # 相似度计算:欧氏距离、Pearson、Tanimoto │ ├── weighted_mean.h # 加权平均 │ └── recommender.h # GetRecommendation 声明 ├── src/ │ ├── similarity.cpp │ ├── weighted_mean.cpp │ ├── recommender.cpp # 核心推荐逻辑 │ └── main.cpp # 加载数据、调用推荐、打印结果 └── CMakeLists.txtsimilarity负责「两个人有多像」,weighted_mean负责「把别人的评分按相似度加权成一个预测分」,recommender把这两件事串起来,main只做数据加载和结果输出。这种拆法的好处是打分函数可以随时替换——项目里用函数指针ScoreFunc实现策略模式,换算法不用动推荐主流程。
2.2 数据加载与内存布局
原始数据是 MovieLens 风格的文本,每行userId::movieId::rating::timestamp。推荐函数需要的是「按人组织的打分表」,所以加载时要转成二维结构:allCritics[personIdx][itemIdx],行是人,列是物品,没打分的格子填 0 表示无效。
// 加载评分数据,构建 personNum x itemNum 的稠密矩阵 // 注意:真实数据里 6040 人 x 3952 部电影约 2400 万个 double, // 约 190MB,内存吃得下;规模再大就得换稀疏存储 std::vector<std::vector<double>> LoadRatings(const std::string& path, size_t personNum, size_t itemNum) { std::vector<std::vector<double>> critics( personNum, std::vector<double>(itemNum, 0.0)); std::ifstream fin(path); std::string line; while (std::getline(fin, line)) { std::stringstream ss(line); std::string u, m, r, t; std::getline(ss, u, ':'); std::getline(ss, m, ':'); std::getline(ss, r, ':'); std::getline(ss, t, ':'); size_t uid = std::stoul(u) - 1; // 原始 id 从 1 开始,转 0 基 size_t mid = std::stoul(m) - 1; double score = std::stod(r); if (uid < personNum && mid < itemNum) { critics[uid][mid] = score; } } return critics; }这里有两个参数要盯住:personNum和itemNum必须和数据集实际规模一致,否则越界或截断。0 作为「未评分」的哨兵值是整个算法的约定——后面所有「有效评分」判断都靠> 0,所以如果你的评分体系允许 0 分,得换成 -1 之类的哨兵。稠密矩阵是这个项目最明显的性能短板,6040×3952 的规模还能接受,用户量上到十万级就必须改成unordered_map<userId, unordered_map<movieId, score>>的稀疏结构,相似度计算时只遍历交集。
2.3 相似度计算:三种算法的实现差异
相似度函数统一签名double(const double*, const double*, size_t),返回越大越相似。三种算法的实现和适用场景差别不小:
// 欧氏距离:先算距离,再用 1/(1+dist) 转成相似度,范围 (0,1] double GetEuclideanDistance(const double* a, const double* b, size_t n) { double sum = 0.0; for (size_t i = 0; i < n; ++i) { if (a[i] > 0 && b[i] > 0) { // 只算共同评分的物品 double d = a[i] - b[i]; sum += d * d; } } return std::sqrt(sum); } // Pearson 相关系数:先算均值,再算协方差/标准差乘积,范围 [-1,1] double GetPearsonCorrelationCoefficient(const double* a, const double* b, size_t n) { double sumA = 0, sumB = 0; size_t cnt = 0; for (size_t i = 0; i < n; ++i) { if (a[i] > 0 && b[i] > 0) { sumA += a[i]; sumB += b[i]; ++cnt; } } if (cnt == 0) return 0.0; double meanA = sumA / cnt, meanB = sumB / cnt; double cov = 0, varA = 0, varB = 0; for (size_t i = 0; i < n; ++i) { if (a[i] > 0 && b[i] > 0) { double da = a[i] - meanA, db = b[i] - meanB; cov += da * db; varA += da * da; varB += db * db; } } if (varA == 0 || varB == 0) return 0.0; // 一方评分全相同,无相关性 return cov / std::sqrt(varA * varB); }欧氏距离对绝对分值敏感,两个人一个习惯打 3 分一个习惯打 5 分,即使口味一致距离也会偏大;Pearson 先减均值,天然消除了「打分尺度」差异,是协同过滤里更常用的选择;Tanimoto 适合布尔型偏好(看过/没看过),在评分场景下用得少。项目里三个都实现了,用函数指针切换,实测 Top20 结果重合度超过 75%,说明三种度量在这个数据集上给出的排序方向基本一致——这也侧面验证了实现没写错。
2.4 核心推荐函数逐段拆解
GetRecommendation是整个项目的心脏,分三步:算当前用户和其他所有人的相似度、对每个物品算加权预测分、排序取 TopN。
typedef double (*ScoreFunc)(const double*, const double*, size_t); void GetRecommendation(const double** allCritics, size_t personNum, size_t size, size_t myIndex, ScoreFunc scorer, size_t recNum, int* recItems, double* recScores) { // 第一步:当前用户与所有人的相似度 std::vector<double> allRels(personNum, 0.0); for (size_t idx = 0; idx < personNum; ++idx) { if (idx == myIndex) continue; // 跳过自己 allRels[idx] = scorer(allCritics[myIndex], allCritics[idx], size); } // 第二步:逐物品计算加权预测分 std::multimap<double, size_t> mapScores; std::vector<double> rels(personNum), critics(personNum); for (size_t itemIdx = 0; itemIdx < size; ++itemIdx) { for (size_t p = 0; p < personNum; ++p) { if (allCritics[p][itemIdx] <= 0) { // 没评过,权重清零 rels[p] = 0; critics[p] = 0; } else { rels[p] = allRels[p]; critics[p] = allCritics[p][itemIdx]; } } double score = GetWeightedMean(critics.data(), critics.data() + personNum, rels.data(), rels.data() + personNum); mapScores.insert(std::make_pair(score, itemIdx)); } // 第三步:multimap 按 key 升序,反向迭代取最高分 auto it = mapScores.rbegin(); for (size_t c = 0; c < recNum && it != mapScores.rend(); ++c, ++it) { recItems[c] = static_cast<int>(it->second); recScores[c] = it->first; } }参数含义逐个说清:allCritics是二维打分表的指针数组,personNum是用户总数,size是物品总数(即每行长度),myIndex是目标用户在矩阵中的行号,scorer是相似度函数指针,recNum是要返回的推荐条数,recItems和recScores是调用方预分配的输出数组。第一步的allRels只算一次,避免在物品循环里重复计算相似度——这是性能上的关键,否则复杂度会从 O(P×I) 涨到 O(P×I×P)。
第二步里那个<= 0判断就是项目正文强调的「没评价的人不能影响打分」:把无效评分的权重和分值同时清零,加权平均时自然不参与。GetWeightedMean的实现是sum(score_i * rel_i) / sum(rel_i),分母是相似度之和而非人数,这样相似度高的人话语权更大,也顺带缓解了「偏执打分」问题——一个和你相似度只有 0.1 的人打 1 分,对结果的影响远小于相似度 0.9 的人打 5 分。
注意:
multimap允许重复 key,如果多部电影预测分相同不会互相覆盖,这点比map安全。但反向迭代取 TopN 时一定要判it != rend(),否则推荐数超过物品数会越界。
3. 编译运行与结果验证:把 60 万条评分跑出片单
3.1 编译与数据准备
项目用 CMake 管理,C++11 就够,不需要第三方库:
mkdir build && cd build cmake .. -DCMAKE_BUILD_TYPE=Release make -j4 ./recommender ../data/ratings.dat 1000 20命令行参数依次是数据文件路径、目标用户下标(0 基)、推荐条数。数据文件需要先转成user::movie::score::timestamp格式,原始数据如果是逗号分隔,用awk -F, '{print $1"::"$2"::"$3"::"$4}'转一下即可。Release 模式很重要,Debug 下 6040 人的相似度矩阵计算会慢一个数量级。
3.2 三种打分函数的结果对比
用第 1000 号用户、推荐 20 部电影,分别跑三种 scorer,结果重合度如下:
| 对比组合 | 重合条数 | 重合率 |
|---|---|---|
| Euclidean vs Pearson | 16 / 20 | 80% |
| Pearson vs Tanimoto | 15 / 20 | 75% |
| Tanimoto vs Euclidean | 16 / 20 | 80% |
重合率超过 75% 说明三种度量捕捉到的「相似用户」群体高度重叠,推荐结果稳定。差异部分主要出现在评分稀疏的用户身上——共同评分物品少于 5 个时,Pearson 的方差估计不可靠,容易给出极端相似度,这时欧氏距离反而更稳。我的做法是给相似度计算加一个最小共同评分阈值,低于阈值直接返回 0,避免噪声用户污染加权。
3.3 怎么判断推荐结果「对不对」
没有线上 A/B 测试的情况下,最实用的验证是留一法:从目标用户的评分记录里随机抽 10 条高分电影(4 分以上)藏起来,用剩下的数据跑推荐,看这 10 部有多少出现在 TopN 里。命中率能到 20% 以上就算这个基线可用了。另一个快速检查是看推荐列表的预测分分布——如果所有分数都挤在 3.5 到 3.6 之间,说明相似度权重区分度不够,大概率是相似度计算里没排除共同评分过少的用户。
4. 避坑与排查:协同过滤最容易翻车的几个地方
4.1 推荐列表里出现用户已经看过的电影
现象:输出的 Top20 里混着目标用户打过高分的片子。原因:推荐循环没有排除myIndex已评分的物品,GetWeightedMean对已评分物品照样算出了高分。解决:在第二步物品循环开头加if (allCritics[myIndex][itemIdx] > 0) continue;,一行代码的事,但漏了就是线上事故。
4.2 相似度全是 0 或全是 1
现象:换任何 scorer 结果都一样,或者推荐列表顺序完全随机。原因:数据加载时用户 id 或电影 id 没有转成 0 基,导致矩阵错位;或者哨兵值判断写成了>= 0,把未评分的 0 当成了有效评分参与计算。解决:加载后打印前 5 行前 5 列核对,确认非零格子位置和原始数据对得上;所有有效性判断统一用> 0。
4.3 运行几分钟没出结果
现象:程序卡在相似度计算阶段。原因:allCritics用double**指针数组传参,如果每行是独立new出来的,内存不连续,缓存命中率极低;更常见的是在物品循环里重复调用了 scorer。解决:相似度只算一次存进allRels;矩阵用vector<vector<double>>保证每行连续,或者干脆拍平成一维vector<double>手动算下标。
4.4 预测分全部相同
现象:TopN 的recScores一模一样。原因:GetWeightedMean的分母用了人数而不是相似度之和,或者相似度没有做归一化,导致加权退化成普通平均。解决:确认分母是sum(rel_i)且对rel_i为 0 的项做了跳过,避免除零。
4.5 内存占用随用户数线性爆炸
现象:用户量翻倍,内存涨四倍。原因:稠密矩阵是 O(P×I),而实际评分只有 O(非零数),MovieLens 这份数据稠密度不到 5%。解决:换成稀疏存储,相似度计算时用双指针遍历两人评分物品的交集,内存能降一个数量级,代价是代码复杂度上升。
5. 二次开发:把函数指针换成可配置的推荐管线
这个项目最值得改的地方是ScoreFunc这个函数指针——它把算法选择做成了运行时参数,但只能选一个。实际做二次开发时,我一般会把它升级成「相似度 + 加权策略」的组合管线,用配置文件驱动:
struct RecConfig { std::string similarity; // "euclidean" / "pearson" / "tanimoto" size_t minCommonItems; // 最小共同评分阈值,低于此值相似度置 0 size_t topKNeighbors; // 只用最相似的 K 个用户参与加权 }; ScoreFunc ResolveScorer(const std::string& name) { if (name == "euclidean") return GetEuclideanScore; if (name == "pearson") return GetPearsonScore; return GetTanimotoScore; }topKNeighbors是性价比最高的一个改造:现在是对全部 6039 个用户加权,但相似度低于 0.3 的用户贡献的基本是噪声,只保留 Top50 邻居,预测质量不降反升,计算量还少一个数量级。minCommonItems则直接解决第 3 章提到的稀疏用户问题。再往下走,可以把「基于用户」扩展成「基于物品」——把矩阵转置,同样的GetRecommendation逻辑直接复用,只是相似度算的是电影与电影之间。我踩过的一个坑是转置后忘了同步调整myIndex的语义,从「用户下标」变成「电影下标」,输出全乱,排查了半天。从那以后我每次改矩阵维度都强制先打印行列数和前几行做核对,再跑推荐。希望这些能帮到你少走点弯路。
本文还有配套的精品资源,点击获取