协同过滤算法深入: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%)
- 自动完成

浙公网安备 33010602011771号