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. 整体流程与架构

下面这张图展示了整个推荐系统的数据流。我习惯把计算相似度做成离线任务,每天凌晨跑一次,结果存文件。推荐服务启动时直接加载,这样响应快。

评分数据 (CSV) 数据加载模块 离线相似度计算 (余弦相似度) 相似度矩阵 (文件/内存) 在线推荐引擎 Top-N 推荐列表 用户请求 (userId) 返回给前端 离线流程 在线流程 外部输入

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)思想,降低热门物品的权重。
  • 实时性:用户刚评完分,希望立刻看到推荐变化。我之前的方案是:离线算基础相似度,在线用用户最近评分做增量更新,效果还行。

好了,这一章的内容就到这里。代码量不大,但背后涉及的工程细节不少。你动手写一遍,踩踩坑,比看十遍文章都管用。


公众号:蓝海资料掘金营,微信deep3321