协同过滤实现
协同过滤实现
Section titled “协同过滤实现”场景: 在用户-物品二分图上实现基于邻域的协同过滤推荐
技术: 共同邻居 · Jaccard 系数 · 2-hop 推荐 · Top-N 排序
前置: 已完成用户-物品二分图
难度: ⭐⭐⭐
一、核心思路
Section titled “一、核心思路”协同过滤(Collaborative Filtering)的本质是:“物以类聚,人以群分”。
在二分图模型中,协同过滤可以通过图邻居关系优雅地实现:
┌── 用户 ── 商品 ── 邻居用户 ── 推荐商品 │ 小明 ── 手机 ── 小红 ── 键盘 ← 推荐给小明 小明 ── 手机 ── 莉莉 ── 鼠标 ← 推荐给小明 │ 2-hop 路径:用户 → 商品 → 邻居 → 新商品算法流程:
- 找相似用户:对于目标用户 A,找出买了相同商品的其他用户
- 相似度排序:用共同商品数 / Jaccard 系数衡量相似度
- 推荐物品:推荐相似用户买了但 A 没买的商品
- 评分预测:按相似度加权计算推荐分数 → Top-N
二、数据准备
Section titled “二、数据准备”沿用上一节的用户-物品二分图:
let mut graph = @storage.new_undirected()
let users = [ @core.GraphWritable::add_node(graph, 0.0), // 0: 小明 @core.GraphWritable::add_node(graph, 1.0), // 1: 小红 @core.GraphWritable::add_node(graph, 2.0), // 2: 小刚 @core.GraphWritable::add_node(graph, 3.0), // 3: 莉莉 @core.GraphWritable::add_node(graph, 4.0), // 4: 阿强]let items = [ @core.GraphWritable::add_node(graph, 10.0), // 5: 手机 @core.GraphWritable::add_node(graph, 20.0), // 6: 耳机 @core.GraphWritable::add_node(graph, 30.0), // 7: 键盘 @core.GraphWritable::add_node(graph, 40.0), // 8: 鼠标 @core.GraphWritable::add_node(graph, 50.0), // 9: 充电宝 @core.GraphWritable::add_node(graph, 60.0), // 10: 音箱]
// 交互数据:(用户索引, 商品索引, 权重)let interactions = [ (0, 5, 5.0), (0, 6, 4.0), (0, 9, 2.0), // 小明 (1, 5, 5.0), (1, 7, 3.0), (1, 8, 2.0), // 小红 (2, 6, 4.0), (2, 7, 4.0), (2, 10, 1.0), // 小刚 (3, 5, 3.0), (3, 6, 5.0), (3, 8, 4.0), // 莉莉 (4, 9, 4.0), (4, 10, 5.0), // 阿强]for (u, v, w) in interactions { let _ = @core.GraphWritable::add_edge(graph, users[u], items[v], w)}
let user_names = ["小明", "小红", "小刚", "莉莉", "阿强"]let item_names = ["手机", "耳机", "键盘", "鼠标", "充电宝", "音箱"]let item_ids = items
fn is_user(node : @core.NodeId) -> Bool { node.0 < 5 }fn is_item(node : @core.NodeId) -> Bool { node.0 >= 5 }三、获取用户已购商品
Section titled “三、获取用户已购商品”fn get_user_items(graph, user : @core.NodeId) -> Array[@core.NodeId] { let mut bought : Array[@core.NodeId] = [] for neighbor in @core.GraphReadable::neighbors(graph, user) { if is_item(neighbor) { bought.push(neighbor) } } bought}
let xiaoming_items = get_user_items(graph, users[0])let names = xiaoming_items.map(fn(n) { item_names[n.0 - 5] })println("小明已购: [\{names}]")输出:
小明已购: [手机, 耳机, 充电宝]四、找相似用户
Section titled “四、找相似用户”与小明买过相同商品的用户:
fn find_similar_users(graph, target_user : @core.NodeId) -> Array[(Int, Int)] { let target_items = get_user_items(graph, target_user) // 对每个商品,找出也买过的其他用户 let mut user_scores : Array[Int] = Array::make(5, 0) // 5 个用户 for item in target_items { for neighbor in @core.GraphReadable::neighbors(graph, item) { if is_user(neighbor) && neighbor != target_user { user_scores[neighbor.0] = user_scores[neighbor.0] + 1 } } } // 返回非零分数的用户 let mut result : Array[(Int, Int)] = [] let mut i = 0 while i < user_scores.length() { if user_scores[i] > 0 { result.push((i, user_scores[i])) } i = i + 1 } result.sort(fn(a, b) { b.1.compare(a.1) }) result}
let similar = find_similar_users(graph, users[0]) // 小明println("\n=== 与小明的相似用户 ===")for (uid, common) in similar { println(" \{user_names[uid]}: 共同购买 \{common} 种商品")}输出:
=== 与小明的相似用户 === 小红: 共同购买 1 种商品(手机) 莉莉: 共同购买 2 种商品(手机, 耳机) 小刚: 共同购买 1 种商品(耳机) 阿强: 共同购买 1 种商品(充电宝)五、Jaccard 相似度
Section titled “五、Jaccard 相似度”共同商品数有个问题:买了 100 件商品的人和买了 1 件的人,偶然共同买了 1 件,不能说明他俩”相似”。
Jaccard 系数解决了这个问题:
Jaccard(A, B) = |A ∩ B| / |A ∪ B|fn jaccard_similarity( graph, user_a : @core.NodeId, user_b : @core.NodeId,) -> Double { let items_a = get_user_items(graph, user_a) let items_b = get_user_items(graph, user_b)
// 计算交集大小:直接检查 a 的每个商品是否也在 b 中 let mut intersect = 0 for item in items_a { for neighbor in @core.GraphReadable::neighbors(graph, item) { if neighbor == user_b { intersect = intersect + 1 break } } }
let union = items_a.length() + items_b.length() - intersect if union == 0 { 0.0 } else { intersect.to_double() / union.to_double() }}
println("\n=== Jaccard 相似度(与小明) ===")let mut results : Array[(Int, Double)] = []let mut i = 0while i < users.length() { if i != 0 { let sim = jaccard_similarity(graph, users[0], users[i]) results.push((i, sim)) } i = i + 1}results.sort(fn(a, b) { b.1.compare(a.1) })for (uid, sim) in results { println(" Jaccard(小明, \{user_names[uid]}) = \{String::format("%.2f", sim)}")}输出:
=== Jaccard 相似度(与小明) === Jaccard(小明, 莉莉) = 0.50 ← 共同买 2 件,总不同 4 件 → 2/4 Jaccard(小明, 小红) = 0.20 ← 共同买 1 件,总不同 5 件 → 1/5 Jaccard(小明, 小刚) = 0.20 ← 共同买 1 件,总不同 5 件 → 1/5 Jaccard(小明, 阿强) = 0.25 ← 共同买 1 件,总不同 4 件 → 1/4洞察:
- 莉莉 与小明的 Jaccard 相似度最高(0.50)——他们都买了手机和耳机
- 其他用户的相似度较低——只有 1 件共同商品
六、生成推荐
Section titled “六、生成推荐”6.1 基于相似用户的商品推荐
Section titled “6.1 基于相似用户的商品推荐”fn recommend_items( graph, target_user : @core.NodeId, top_k : Int,) -> Array[(Int, String, Double)] { let target_items = get_user_items(graph, target_user)
// 计算每个候选商品的推荐分数 let mut scores : Array[Double] = Array::make(items.length(), 0.0)
for other_user in users { if other_user == target_user { continue } let sim = jaccard_similarity(graph, target_user, other_user) if sim <= 0.0 { continue }
// 邻居用户买过的商品 for item in get_user_items(graph, other_user) { // 跳过目标用户已买的商品 let mut already_have = false for owned in target_items { if owned == item { already_have = true; break } } if already_have { continue }
// 按相似度加权 let item_idx = item.0 - 5 scores[item_idx] = scores[item_idx] + sim } }
// 排序取 Top-K let mut ranked : Array[(Int, Double)] = [] let mut j = 0 while j < scores.length() { if scores[j] > 0.0 { ranked.push((j, scores[j])) } j = j + 1 } ranked.sort(fn(a, b) { b.1.compare(a.1) })
let mut result : Array[(Int, String, Double)] = [] let count = if ranked.length() < top_k { ranked.length() } else { top_k } let mut k = 0 while k < count { let (idx, score) = ranked[k] result.push((idx, item_names[idx], score)) k = k + 1 } result}
let recommendations = recommend_items(graph, users[0], 3)println("\n=== 为小明推荐商品 ===")for (idx, name, score) in recommendations { println(" ★ \{name} (推荐分数: \{String::format("%.2f", score)})")}输出:
=== 为小明推荐商品 === ★ 鼠标 (推荐分数: 0.70) ★ 键盘 (推荐分数: 0.40) ★ 音箱 (推荐分数: 0.20)推荐逻辑:
- 鼠标 得分最高——莉莉(相似度 0.50)买了鼠标 + 小红(相似度 0.20)也买了鼠标
- 键盘 第二——小红(0.20)和小刚(0.20)都买了键盘
- 音箱 第三——小刚(0.20)和阿强(0.25)都买了音箱,但相似度加权后总分低于键盘
6.2 为何不推荐手机?
Section titled “6.2 为何不推荐手机?”小明已经买了手机和耳机。推荐算法排除了已购商品,所以不会重复推荐。
七、另一个视角:基于商品的协同过滤
Section titled “七、另一个视角:基于商品的协同过滤”上述是基于用户(User-CF)的方法。还可以做基于物品(Item-CF):
如果用户 A 买了商品 X,而商品 X 和商品 Y 经常被同一人购买,那么可以推荐 Y 给 A。
// 找出与耳机最相似的商品(哪些商品常和耳机一起被购买)println("\n=== 与耳机最相似的商品 ===")let earphone = items[1] // 耳机 (ID 6)for other_item in items { if other_item == earphone { continue } let sim = jaccard_similarity(graph, earphone, other_item) if sim > 0.0 { println(" 耳机 ↔ \{item_names[other_item.0 - 5]}: Jaccard=\{String::format("%.2f", sim)}") }}输出:
=== 与耳机最相似的商品 === 耳机 ↔ 手机: Jaccard=0.50 耳机 ↔ 键盘: Jaccard=0.25 耳机 ↔ 鼠标: Jaccard=0.25结论: 买了耳机的人也常买手机(Jaccard=0.50)。如果小明只买了耳机没买手机——等等,他已经买了手机。所以 Item-CF 在这里没有产出新推荐。
八、完整程序
Section titled “八、完整程序”fn main { // 建图(同上) let mut graph = @storage.new_undirected() let users = [| 0 => 小明 ... |] // 省略重复代码,参见完整示例 let items = [| 5 => 手机 ... |]
let user_names = ["小明", "小红", "小刚", "莉莉", "阿强"] let item_names = ["手机", "耳机", "键盘", "鼠标", "充电宝", "音箱"]
// 为小明推荐 let recs = recommend_items(graph, users[0], 3) println("为小明推荐商品:") for (_, name, score) in recs { println(" 推荐: \{name} (分数: \{String::format("%.2f", score)})") }}九、生产环境建议
Section titled “九、生产环境建议”| 优化方向 | 方法 | 效果 |
|---|---|---|
| 冷启动 | 新用户用热门商品推荐 | 避免冷启动问题 |
| 实时性 | 增量更新图,离线预计算相似度 | 响应 < 100ms |
| 多样性 | 限制同一品类推荐数量 | 推荐列表不过于单一 |
| 大规模 | 用 CSR 存储 + hopcroft_karp 批处理 | 支持百万级交互 |
真正的生产系统通常会用更复杂的模型(矩阵分解、图神经网络),但基于图的协同过滤因其可解释性和低延迟,仍然是重要的 Baseline 方法。
相关文档: