70、C++项目实战:推荐系统(协同过滤)
推荐系统,说白了就是猜你喜欢什么。你刷短视频、逛电商、听音乐,背后都有它的影子。我最早接触这玩意儿是在一个音乐App项目里,当时产品经理丢过来一句话:“用户听了A,就把B推给他。” 嗯,听起来简单,但真动手写代码,坑不少。
这一章,咱们就用C++手写一个基于协同过滤的推荐引擎。不依赖第三方库,纯C++17,从数据加载到相似度计算,再到推荐生成,一条龙走完。
1. 协同过滤的核心思想
协同过滤分两种:
- 基于用户(User-Based):找和你口味相似的人,把他们喜欢的推荐给你。
- 基于物品(Item-Based):找你喜欢的物品的“孪生兄弟”,推荐相似的物品。
我个人习惯先用基于物品的。为什么?因为用户数量通常远大于物品数量,而且物品的相似度相对稳定,可以离线算好存起来。你想想看,一个电商平台几亿用户,实时算用户相似度,服务器扛不住。
核心公式:余弦相似度
计算两个物品(或用户)的相似度,最常用的是余弦相似度:
similarity = (A·B) / (||A|| * ||B||)
其中A和B是评分向量,没评分的项补0。
2. 数据结构的选型
我建议用稀疏矩阵来存评分数据。C++里没有现成的稀疏矩阵,但我们可以用 std::unordered_map 嵌套实现。
// 用户ID -> (物品ID -> 评分)
using RatingMatrix = std::unordered_map<int, std::unordered_map<int, float>>;
// 物品ID -> (物品ID -> 相似度)
using SimilarityMatrix = std::unordered_map<int, std::unordered_map<int, float>>;
我在项目中遇到过一个问题:直接用 map 存,数据量一大,内存暴涨。后来改成 unordered_map,配合自定义哈希,性能提升不少。嗯,这里要注意,哈希函数要写好,不然碰撞多了反而更慢。
3. 数据加载与预处理
假设我们有一个评分文件 ratings.csv,格式是 userId,itemId,rating。加载代码很简单:
RatingMatrix loadRatings(const std::string& path) {
RatingMatrix data;
std::ifstream file(path);
std::string line;
while (std::getline(file, line)) {
std::stringstream ss(line);
int uid, iid;
float rating;
char comma;
ss >> uid >> comma >> iid >> comma >> rating;
data[uid][iid] = rating;
}
return data;
}
这里有个小坑:文件里可能有重复评分。我曾经没做去重,结果推荐结果飘忽不定。后来加了个判断,如果已有评分,取最后一次或平均值,看业务需求。
4. 计算物品相似度
这是核心。我们遍历所有物品对,计算余弦相似度。为了效率,先构建一个“物品-用户”倒排索引。
SimilarityMatrix computeItemSimilarity(const RatingMatrix& ratings) {
// 构建物品-用户倒排表
std::unordered_map<int, std::unordered_map<int, float>> itemUsers;
for (auto& [uid, items] : ratings) {
for (auto& [iid, rating] : items) {
itemUsers[iid][uid] = rating;
}
}
SimilarityMatrix sim;
std::vector<int> itemIds;
for (auto& [iid, _] : itemUsers) itemIds.push_back(iid);
for (size_t i = 0; i < itemIds.size(); ++i) {
for (size_t j = i + 1; j < itemIds.size(); ++j) {
int a = itemIds[i], b = itemIds[j];
float dot = 0, normA = 0, normB = 0;
// 只遍历共同评分的用户
for (auto& [uid, ra] : itemUsers[a]) {
if (itemUsers[b].count(uid)) {
float rb = itemUsers[b][uid];
dot += ra * rb;
normA += ra * ra;
normB += rb * rb;
}
}
if (normA > 0 && normB > 0) {
float cosine = dot / (std::sqrt(normA) * std::sqrt(normB));
sim[a][b] = sim[b][a] = cosine;
}
}
}
return sim;
}
优化小技巧: 如果物品数量上万,O(n²) 的复杂度吃不消。我一般会先过滤掉评分数量太少的物品(比如少于5个用户评分的物品直接扔掉),或者用局部敏感哈希(LSH)做近似计算。但这里咱们保持简单,聚焦原理。
5. 生成推荐
给定一个用户,找出他还没评分的物品,然后根据他评过分的物品的相似度加权求和,得到预测评分。
std::vector<std::pair<int, float>> recommend(
const RatingMatrix& ratings,
const SimilarityMatrix& sim,
int userId,
int topN = 10) {
auto& userItems = ratings.at(userId);
std::unordered_map<int, float> scores;
std::unordered_map<int, float> weights;
for (auto& [ratedItem, rating] : userItems) {
if (!sim.count(ratedItem)) continue;
for (auto& [candidate, similarity] : sim.at(ratedItem)) {
if (userItems.count(candidate)) continue; // 已评分的跳过
scores[candidate] += similarity * rating;
weights[candidate] += std::abs(similarity);
}
}
std::vector<std::pair<int, float>> result;
for (auto& [item, score] : scores) {
if (weights[item] > 0) {
result.emplace_back(item, score / weights[item]);
}
}
std::sort(result.begin(), result.end(),
[](auto& a, auto& b) { return a.second > b.second; });
if (result.size() > topN) result.resize(topN);
return result;
}
注意: 这里有个冷启动问题。新用户没有任何评分,推荐列表为空。我之前的做法是:对新用户直接推热门物品,等他有3个以上评分后再用协同过滤。你也可以用基于内容的推荐做补充。
6. 整体流程与架构
下面这张图展示了整个推荐系统的数据流。我习惯把计算相似度做成离线任务,每天凌晨跑一次,结果存文件。推荐服务启动时直接加载,这样响应快。
7. 完整代码骨架
我把上面几个模块拼起来,就是一个可运行的推荐系统。main函数里只需要三步:
int main() {
// 1. 加载数据
auto ratings = loadRatings("ratings.csv");
// 2. 计算相似度(离线)
auto sim = computeItemSimilarity(ratings);
// 3. 给用户123推荐
auto recs = recommend(ratings, sim, 123, 10);
for (auto& [item, score] : recs) {
std::cout << "推荐物品 " << item
<< ",预测评分 " << score << "\n";
}
return 0;
}
扩展思路: 你可以把相似度矩阵序列化到磁盘,下次启动直接加载,不用重新算。我常用 boost::serialization 或者自己写二进制格式,比文本快10倍。
8. 避坑指南
- 评分稀疏问题:大部分物品只有少数人评过分,余弦相似度算出来全是0。我建议至少要有3个共同评分用户才计算相似度。
- 热门物品偏差:热门物品容易被推荐给所有人。可以引入“逆文档频率”(IDF)思想,降低热门物品的权重。
- 实时性:用户刚评完分,希望立刻看到推荐变化。我之前的方案是:离线算基础相似度,在线用用户最近评分做增量更新,效果还行。
好了,这一章的内容就到这里。代码量不大,但背后涉及的工程细节不少。你动手写一遍,踩踩坑,比看十遍文章都管用。