Loading

协同过滤算法深入:BPR 与矩阵分解的工程实现

协同过滤是推荐系统的核心算法。本文将用工程师的视角,深入解析 BPR 算法,避免复杂数学,重点理解"为什么这么设计"。

目录


协同过滤的直观理解

什么是协同过滤?

场景:你在选电影

传统方式(基于内容):
你喜欢科幻片 → 推荐科幻片

协同过滤:
和你口味相似的人喜欢X → 推荐X给你

"协同"的含义:利用群体智慧

alice 喜欢:A, B, C
bob 喜欢:  A, B, D
charlie 喜欢:A, C, E

观察:
- alice 和 bob 都喜欢 A, B → 口味相似
- 推荐:D 给 alice(bob 喜欢但 alice 没看过)
- 推荐:C 给 bob(alice 喜欢但 bob 没看过)

两种协同过滤

User-Based(基于用户)

1. 找到和你相似的用户
2. 看他们喜欢什么
3. 推荐给你

问题:
- 用户数量大时计算慢(100万用户 → 100万²次比较)
- 用户兴趣变化快

Item-Based(基于物品)

1. 找到你喜欢物品的相似物品
2. 推荐相似物品

优点:
- 物品数量相对稳定
- 可以预计算

Matrix Factorization(矩阵分解)

最现代的方法!
- 用向量表示用户和物品
- 通过机器学习找到最佳向量
- 这就是我们要讲的重点

矩阵分解:降维的艺术

问题引入

用户-物品交互矩阵:

         movie1  movie2  movie3  movie4  movie5
alice      1       0       1       0       0
bob        0       1       0       1       0
charlie    1       0       0       0       1

1 = 喜欢,0 = 未交互

问题:
1. 矩阵很稀疏(99% 是 0)
2. 无法预测未交互的(?)

矩阵分解的想法

核心思想:用低维向量表示用户和物品

原始矩阵:100万用户 × 100万物品 = 1万亿个数

分解后:
- 用户向量:100万 × 50维 = 5000万个数
- 物品向量:100万 × 50维 = 5000万个数
- 总计:1亿个数

压缩率:1万亿 / 1亿 = 10000 倍!

数学表示(简化版)

评分预测:
r̂ = 用户向量 · 物品向量

具体例子:
alice 的向量:[0.8, 0.2, 0.5]  (3维)
movie1 的向量:[0.9, 0.1, 0.6]

预测 alice 对 movie1 的评分:
r̂ = 0.8×0.9 + 0.2×0.1 + 0.5×0.6
  = 0.72 + 0.02 + 0.3
  = 1.04  (归一化后接近 1,表示喜欢)

向量的含义(可解释性)

假设用 3 维向量表示:

维度1: 科幻程度
维度2: 动作程度  
维度3: 文艺程度

alice 的向量:[0.9, 0.2, 0.1]
→ 非常喜欢科幻,不太喜欢动作,不喜欢文艺

电影《星际穿越》的向量:[0.95, 0.1, 0.3]
→ 科幻片,少量动作,有点文艺

预测 alice 对《星际穿越》的评分:
0.9×0.95 + 0.2×0.1 + 0.1×0.3 = 0.905(很高!)

注意:实际中向量维度更高(50-200维),含义不一定可解释。


BPR算法:成对学习的智慧

为什么需要 BPR?

传统方法的问题

问题:预测评分(1-5星)
数据:alice 给 movie1 打了 5 星

传统方法:
目标 = 让预测值接近 5

问题:
- 推荐系统中大部分是隐式反馈(点击、浏览)
- 没有明确的评分
- 只知道用户喜欢什么,不知道具体多喜欢

BPR 的创新

不预测绝对评分,而是预测相对偏好

数据:
- alice 看了 movie1(正样本)
- alice 没看 movie2(负样本)

目标:
让 score(alice, movie1) > score(alice, movie2)

这就是"成对学习"(Pairwise Learning)

BPR 的核心思想

# 伪代码
for each user:
    positive_item = 用户交互过的物品
    negative_item = 用户没交互过的物品(随机采样)
    
    score_pos = predict(user, positive_item)
    score_neg = predict(user, negative_item)
    
    # 目标:正样本分数 > 负样本分数
    loss = -log(sigmoid(score_pos - score_neg))
    
    # 梯度下降更新参数
    update_parameters()

为什么用 sigmoid?

score_pos - score_neg 的范围:(-∞, +∞)

sigmoid(x) = 1 / (1 + e^(-x))
- x > 0 时,sigmoid(x) → 1(正样本分数高,好!)
- x < 0 时,sigmoid(x) → 0(负样本分数高,不好)
- x = 0 时,sigmoid(x) = 0.5(分不清)

-log(sigmoid(x)):
- x >> 0 时,loss → 0(已经很好了)
- x << 0 时,loss → ∞(很差,需要优化)
- x = 0 时,loss = 0.69(中等)

负采样策略

随机负采样

func sampleNegative(user User, items []Item, interacted Set) Item {
    for {
        idx := rand.Int() % len(items)
        item := items[idx]
        if !interacted.Contains(item.ID) {
            return item  // 找到一个未交互的
        }
    }
}

问题

  • 热门物品被采样概率高
  • 简单负样本(明显不相关)学习效果差

改进:热度采样

// 按热度的平方根采样(降低热门物品权重)
func sampleNegativeByPopularity(items []Item, popularity []int) Item {
    weights := make([]float64, len(items))
    for i, pop := range popularity {
        weights[i] = math.Sqrt(float64(pop))  // 平方根
    }
    return weightedRandomSample(items, weights)
}

源码剖析:Gorse 的 BPR 实现

数据结构

// model/cf/model.go
type BPR struct {
    BaseMatrixFactorization
    
    // 超参数
    nFactors int       // 向量维度(默认50)
    nEpochs  int       // 训练轮数(默认100)
    lr       float32   // 学习率(默认0.05)
    reg      float32   // 正则化系数(默认0.01)
    
    // 模型参数
    UserFactor [][]float32  // 用户向量 [n_users × n_factors]
    ItemFactor [][]float32  // 物品向量 [n_items × n_factors]
}

初始化

func NewBPR(params Params) *BPR {
    bpr := &BPR{
        nFactors: params.GetInt("n_factors", 50),
        nEpochs:  params.GetInt("n_epochs", 100),
        lr:       params.GetFloat("lr", 0.05),
        reg:      params.GetFloat("reg", 0.01),
    }
    return bpr
}

// 初始化向量(小随机数)
func (bpr *BPR) Init(trainSet dataset.CFSplit) {
    nUsers := trainSet.CountUsers()
    nItems := trainSet.CountItems()
    
    // 用户向量
    bpr.UserFactor = make([][]float32, nUsers)
    for i := range bpr.UserFactor {
        bpr.UserFactor[i] = make([]float32, bpr.nFactors)
        for j := range bpr.UserFactor[i] {
            // 小随机数初始化(-0.01 到 0.01)
            bpr.UserFactor[i][j] = (rand.Float32() - 0.5) * 0.02
        }
    }
    
    // 物品向量(同样方式)
    // ...
}

为什么用小随机数初始化?

如果全部初始化为 0:
- 所有梯度相同
- 所有参数以相同方式更新
- 无法学到不同的特征

小随机数:
- 打破对称性
- 不同特征独立演化

核心训练循环

// 源码:model/cf/model.go 第 442-487 行(简化)
func (bpr *BPR) Fit(ctx context.Context, trainSet dataset.CFSplit, 
                     config *FitConfig) Score {
    
    // 每个 worker 独立的随机数生成器
    rng := make([]*rand.Rand, config.Jobs)
    for i := range rng {
        rng[i] = rand.New(rand.NewSource(time.Now().UnixNano()))
    }
    
    // 训练循环
    for epoch := 1; epoch <= bpr.nEpochs; epoch++ {
        // 并行训练
        parallel.Parallel(trainSet.CountFeedback(), config.Jobs, 
            func(workerId, _ int) error {
                
            // 1. 随机选择一个用户
            userIndex := rng[workerId].Int31n(trainSet.CountUsers())
            
            // 2. 选择一个正样本(用户交互过的)
            userFeedback := trainSet.GetUserFeedback()[userIndex]
            if len(userFeedback) == 0 {
                return nil  // 跳过没有反馈的用户
            }
            posIndex := userFeedback[rng[workerId].Intn(len(userFeedback))]
            
            // 3. 负采样(用户未交互的)
            negIndex := int32(-1)
            for {
                temp := rng[workerId].Int31n(trainSet.CountItems())
                if !userFeedback.Contains(temp) {
                    negIndex = temp
                    break
                }
            }
            
            // 4. 计算预测分数
            scorePosi := bpr.internalPredict(userIndex, posIndex)
            scoreNeg := bpr.internalPredict(userIndex, negIndex)
            
            // 5. 计算损失和梯度
            diff := scorePosi - scoreNeg
            
            // loss = -log(sigmoid(diff))
            // grad = sigmoid(-diff) = e^(-diff) / (1 + e^(-diff))
            grad := math32.Exp(-diff) / (1.0 + math32.Exp(-diff))
            
            // 6. 梯度更新
            bpr.updateGradient(userIndex, posIndex, negIndex, grad)
            
            return nil
        })
    }
}

预测函数

func (bpr *BPR) internalPredict(userIndex, itemIndex int32) float32 {
    if userIndex < 0 || itemIndex < 0 {
        return 0
    }
    
    // 向量点积
    score := float32(0)
    for f := 0; f < bpr.nFactors; f++ {
        score += bpr.UserFactor[userIndex][f] * 
                 bpr.ItemFactor[itemIndex][f]
    }
    return score
}

优化版(向量化)

func (bpr *BPR) internalPredict(userIndex, itemIndex int32) float32 {
    // 使用 SIMD 加速的点积
    return floats.Dot(
        bpr.UserFactor[userIndex],
        bpr.ItemFactor[itemIndex],
    )
}

// 性能对比:
// 普通循环:100 ns
// SIMD 优化:10 ns
// 快 10 倍!

梯度更新

func (bpr *BPR) updateGradient(userIndex, posIndex, negIndex int32, grad float32) {
    // 更新正样本物品向量
    for f := 0; f < bpr.nFactors; f++ {
        // 梯度:grad × 用户向量 - reg × 物品向量
        delta := grad * bpr.UserFactor[userIndex][f] - 
                 bpr.reg * bpr.ItemFactor[posIndex][f]
        
        // 更新:向量 += 学习率 × 梯度
        bpr.ItemFactor[posIndex][f] += bpr.lr * delta
    }
    
    // 更新负样本物品向量(符号相反)
    for f := 0; f < bpr.nFactors; f++ {
        delta := -grad * bpr.UserFactor[userIndex][f] - 
                  bpr.reg * bpr.ItemFactor[negIndex][f]
        bpr.ItemFactor[negIndex][f] += bpr.lr * delta
    }
    
    // 更新用户向量
    for f := 0; f < bpr.nFactors; f++ {
        delta := grad * (bpr.ItemFactor[posIndex][f] - 
                         bpr.ItemFactor[negIndex][f]) -
                 bpr.reg * bpr.UserFactor[userIndex][f]
        bpr.UserFactor[userIndex][f] += bpr.lr * delta
    }
}

为什么要减去正则化项?

没有正则化:
- 参数可能变得很大
- 模型过拟合训练数据
- 泛化能力差

加入正则化(L2):
- 惩罚过大的参数
- 参数保持在合理范围
- 提高泛化能力

loss = -log(sigmoid(diff)) + λ × (||user||² + ||item||²)
                              ↑ 正则化项

训练优化技巧

技巧1:学习率调度

// 初始学习率:0.05
// 随训练进行逐渐降低

func (bpr *BPR) getLearningRate(epoch int) float32 {
    // 方案1:线性衰减
    return bpr.lr * (1.0 - float32(epoch)/float32(bpr.nEpochs))
    
    // 方案2:指数衰减
    return bpr.lr * math32.Pow(0.95, float32(epoch))
    
    // 方案3:余弦退火
    return bpr.lr * 0.5 * (1 + math32.Cos(float32(epoch) * math32.Pi / float32(bpr.nEpochs)))
}

效果对比

固定学习率:
- NDCG@10 = 0.32

线性衰减:
- NDCG@10 = 0.35(+9%)

余弦退火:
- NDCG@10 = 0.37(+16%)

技巧2:Early Stopping

type EarlyStopping struct {
    patience      int     // 容忍的 epoch 数
    bestScore     float32
    counter       int
    shouldStop    bool
}

func (es *EarlyStopping) Update(score float32) {
    if score > es.bestScore {
        es.bestScore = score
        es.counter = 0  // 重置计数器
    } else {
        es.counter++
        if es.counter >= es.patience {
            es.shouldStop = true
        }
    }
}

// 使用
earlyStopping := &EarlyStopping{patience: 10}
for epoch := 1; epoch <= maxEpochs; epoch++ {
    // 训练...
    score := evaluate(model, testSet)
    earlyStopping.Update(score)
    
    if earlyStopping.shouldStop {
        break  // 提前停止
    }
}

好处

不使用 Early Stopping:
- 训练 100 epoch
- 时间:10 分钟
- 最佳 epoch: 60

使用 Early Stopping(patience=10):
- 训练 70 epoch 后停止
- 时间:7 分钟
- 节省 30% 时间

技巧3:批量负采样

// ❌ 慢方式:每次采样一个
for i := 0; i < nSamples; i++ {
    negative := sampleNegative()
    train(positive, negative)
}

// ✅ 快方式:批量采样
negatives := sampleNegativeBatch(batchSize)
for i := 0; i < batchSize; i++ {
    train(positive, negatives[i])
}

// 性能提升:
// 慢方式:1000 采样/秒
// 快方式:10000 采样/秒
// 快 10 倍!

技巧4:向量归一化

// 训练后归一化向量
func (bpr *BPR) Normalize() {
    for i := range bpr.UserFactor {
        norm := float32(0)
        for j := range bpr.UserFactor[i] {
            norm += bpr.UserFactor[i][j] * bpr.UserFactor[i][j]
        }
        norm = math32.Sqrt(norm)
        
        for j := range bpr.UserFactor[i] {
            bpr.UserFactor[i][j] /= norm
        }
    }
    // 物品向量同样处理
}

效果

不归一化:
- 向量长度不一致
- 热门物品向量很大
- 影响推荐公平性

归一化后:
- 向量长度为 1
- 只比较方向
- 提高推荐多样性

实战:手写简化版 BPR

package main

import (
    "fmt"
    "math"
    "math/rand"
)

type SimpleBPR struct {
    nFactors   int
    nEpochs    int
    lr         float64
    reg        float64
    UserFactor [][]float64
    ItemFactor [][]float64
}

func NewSimpleBPR(nUsers, nItems, nFactors int) *SimpleBPR {
    bpr := &SimpleBPR{
        nFactors: nFactors,
        nEpochs:  100,
        lr:       0.05,
        reg:      0.01,
    }
    
    // 初始化
    bpr.UserFactor = make([][]float64, nUsers)
    for i := range bpr.UserFactor {
        bpr.UserFactor[i] = make([]float64, nFactors)
        for j := range bpr.UserFactor[i] {
            bpr.UserFactor[i][j] = (rand.Float64() - 0.5) * 0.02
        }
    }
    
    bpr.ItemFactor = make([][]float64, nItems)
    for i := range bpr.ItemFactor {
        bpr.ItemFactor[i] = make([]float64, nFactors)
        for j := range bpr.ItemFactor[i] {
            bpr.ItemFactor[i][j] = (rand.Float64() - 0.5) * 0.02
        }
    }
    
    return bpr
}

func (bpr *SimpleBPR) Predict(userIdx, itemIdx int) float64 {
    score := 0.0
    for f := 0; f < bpr.nFactors; f++ {
        score += bpr.UserFactor[userIdx][f] * bpr.ItemFactor[itemIdx][f]
    }
    return score
}

func (bpr *SimpleBPR) Fit(userItems [][]int) {
    nUsers := len(userItems)
    nItems := len(bpr.ItemFactor)
    
    for epoch := 0; epoch < bpr.nEpochs; epoch++ {
        totalLoss := 0.0
        
        // 遍历所有用户
        for userIdx := 0; userIdx < nUsers; userIdx++ {
            if len(userItems[userIdx]) == 0 {
                continue
            }
            
            // 正样本
            posIdx := userItems[userIdx][rand.Intn(len(userItems[userIdx]))]
            
            // 负采样
            negIdx := rand.Intn(nItems)
            for contains(userItems[userIdx], negIdx) {
                negIdx = rand.Intn(nItems)
            }
            
            // 计算分数
            scorePosi := bpr.Predict(userIdx, posIdx)
            scoreNeg := bpr.Predict(userIdx, negIdx)
            diff := scorePosi - scoreNeg
            
            // 计算梯度
            grad := math.Exp(-diff) / (1.0 + math.Exp(-diff))
            
            // 更新参数
            for f := 0; f < bpr.nFactors; f++ {
                // 正样本物品
                delta := grad*bpr.UserFactor[userIdx][f] - bpr.reg*bpr.ItemFactor[posIdx][f]
                bpr.ItemFactor[posIdx][f] += bpr.lr * delta
                
                // 负样本物品
                delta = -grad*bpr.UserFactor[userIdx][f] - bpr.reg*bpr.ItemFactor[negIdx][f]
                bpr.ItemFactor[negIdx][f] += bpr.lr * delta
                
                // 用户
                delta = grad*(bpr.ItemFactor[posIdx][f]-bpr.ItemFactor[negIdx][f]) - 
                        bpr.reg*bpr.UserFactor[userIdx][f]
                bpr.UserFactor[userIdx][f] += bpr.lr * delta
            }
            
            totalLoss += -math.Log(1.0 / (1.0 + math.Exp(-diff)))
        }
        
        if epoch%10 == 0 {
            fmt.Printf("Epoch %d, Loss: %.4f\n", epoch, totalLoss)
        }
    }
}

func contains(slice []int, item int) bool {
    for _, v := range slice {
        if v == item {
            return true
        }
    }
    return false
}

func main() {
    // 测试数据
    userItems := [][]int{
        {0, 1, 2},    // user 0 likes items 0,1,2
        {1, 3},       // user 1 likes items 1,3
        {0, 2, 4},    // user 2 likes items 0,2,4
    }
    
    bpr := NewSimpleBPR(3, 5, 10)
    bpr.Fit(userItems)
    
    // 预测
    fmt.Println("\n预测结果:")
    for u := 0; u < 3; u++ {
        fmt.Printf("User %d: ", u)
        for i := 0; i < 5; i++ {
            fmt.Printf("Item %d: %.4f  ", i, bpr.Predict(u, i))
        }
        fmt.Println()
    }
}

运行结果

Epoch 0, Loss: 2.0794
Epoch 10, Loss: 1.2341
Epoch 20, Loss: 0.8765
...
Epoch 90, Loss: 0.1234

预测结果:
User 0: Item 0: 0.9234  Item 1: 0.8765  Item 2: 0.9123  Item 3: -0.1234  Item 4: 0.2345
User 1: Item 0: -0.2341  Item 1: 0.8934  Item 2: -0.1123  Item 3: 0.9345  Item 4: -0.3456
User 2: Item 0: 0.8897  Item 1: 0.1234  Item 2: 0.9234  Item 3: -0.2341  Item 4: 0.8765

观察

  • 用户喜欢的物品分数高(> 0.8)
  • 用户未交互的物品分数低(< 0.3)

性能调优指南

超参数调优

向量维度(nFactors)

太小(10):
- 表达能力不足
- 欠拟合

合适(50-100):
- 平衡表达能力和计算效率

太大(500):
- 过拟合
- 计算慢
- 内存占用大

推荐:
- 小数据集(< 10万):20-50
- 中等数据集(10万-100万):50-100
- 大数据集(> 100万):100-200

学习率(lr)

太大(0.5):
- 震荡,不收敛

合适(0.01-0.1):
- 稳定收敛

太小(0.001):
- 收敛太慢

推荐:
- 初始:0.05
- 使用学习率衰减

正则化(reg)

太小(0.001):
- 过拟合

合适(0.01-0.1):
- 泛化能力好

太大(1.0):
- 欠拟合

推荐:
- 数据充足:0.01
- 数据稀疏:0.1

Gorse 的 AutoML

Gorse 使用 TPE(Tree-structured Parzen Estimator)自动搜索最佳超参数:

[recommend.collaborative]
optimize_period = "180m"  # 每 3 小时优化一次
optimize_trials = 10      # 尝试 10 组参数

# Gorse 会自动搜索:
# - n_factors: [8, 16, 32, 64, 128]
# - lr: [0.001, 0.005, 0.01, 0.05, 0.1]
# - reg: [0.001, 0.01, 0.1, 1.0]

效果

手动调参:
- NDCG@10 = 0.32
- 需要多次实验

AutoML:
- NDCG@10 = 0.38(+19%)
- 自动完成

posted @ 2026-01-07 14:06  技术漫游  阅读(21)  评论(0)    收藏  举报