Loading

Gorse 协同过滤模型训练详解

目录

  1. 协同过滤概述
  2. 矩阵分解原理
  3. 两大算法对比
  4. BPR 算法详解
  5. ALS 算法详解
  6. 训练流程
  7. 超参数调优
  8. 模型评估
  9. 实战示例

协同过滤概述

什么是协同过滤?

协同过滤(Collaborative Filtering) 是推荐系统中最经典的算法,通过分析"用户-物品"交互历史来预测用户对未交互物品的偏好。

核心思想:

"物以类聚,人以群分"
- 相似的用户喜欢相似的物品
- 喜欢某物品的用户,也会喜欢类似物品

为什么需要协同过滤?

问题场景:
- 用户 A 看过电影 1, 2, 3
- 用户 B 看过电影 1, 2, 4
- 用户 A 和 B 兴趣相似(都喜欢 1, 2)
- 那么可以推荐:
  - 给 A 推荐电影 4(B 看过)
  - 给 B 推荐电影 3(A 看过)

Gorse 中的协同过滤

Gorse 使用矩阵分解(Matrix Factorization)实现协同过滤,支持两种算法:

算法 全称 适用场景 训练方式
BPR Bayesian Personalized Ranking 隐式反馈(点击、浏览) SGD(随机梯度下降)
ALS Alternating Least Squares 隐式反馈 + 评分预测 交替最小二乘

矩阵分解原理

用户-物品交互矩阵

假设有 3 个用户和 4 个物品:

          物品1  物品2  物品3  物品4
用户A      1      1      0      ?
用户B      1      0      1      ?
用户C      0      1      1      ?
  • 1 表示有交互(点击、购买等)
  • 0 表示无交互
  • ? 表示需要预测的

问题:这个矩阵非常稀疏(大部分是 0),如何填充 ?

矩阵分解的核心思想

稀疏的交互矩阵分解为两个低维稠密矩阵

用户-物品矩阵 (M×N) = 用户向量矩阵 (M×K) × 物品向量矩阵 (K×N)

M: 用户数
N: 物品数
K: 隐向量维度(通常 10-100)

数学表示

R ≈ P × Q^T

R[u,i] ≈ P[u] · Q[i]  (向量点积)
  • R: 用户-物品交互矩阵
  • P[u]: 用户 u 的隐向量(K 维)
  • Q[i]: 物品 i 的隐向量(K 维)

直观理解

假设 K=2(两个隐因子):

用户 A 的向量: [0.8, 0.3]  → 80% 喜欢"动作片",30% 喜欢"爱情片"
物品 1 的向量: [0.9, 0.1]  → 90% 是"动作片",10% 是"爱情片"

预测评分: 0.8 × 0.9 + 0.3 × 0.1 = 0.72 + 0.03 = 0.75 (高分,推荐!)

为什么要降维?

原始矩阵 分解后
100万用户 × 10万物品 = 1000亿个元素 100万×16 + 16×10万 = 1760万个元素
99% 是 0(稀疏) 100% 有值(稠密)
无法泛化(过拟合) 能够泛化(学到模式)

两大算法对比

BPR vs ALS

维度 BPR ALS
全称 Bayesian Personalized Ranking Alternating Least Squares
优化目标 最大化正样本排名高于负样本 最小化预测误差
损失函数 Pairwise Loss(成对损失) Pointwise Loss(逐点损失)
训练方式 SGD(随机梯度下降) 交替最小二乘(封闭解)
适用场景 排序任务(Top-N 推荐) 评分预测 + 排序
训练速度 较慢(每次一个样本) 较快(并行优化)
内存占用 较小 较大
默认选择 ✅ Gorse 默认 可选

何时选择 BPR?

✅ 适合:
- 只有隐式反馈(点击、浏览、购买)
- 关注排序(Top-N 推荐)
- 数据量大,需要在线训练

❌ 不适合:
- 需要评分预测(用 ALS)
- 数据量小(用简单的 ItemCF)

何时选择 ALS?

✅ 适合:
- 需要评分预测
- 数据量大,训练时间充足
- 可以并行化训练

❌ 不适合:
- 纯排序任务(BPR 更好)
- 内存受限(ALS 需要更多内存)

BPR 算法详解

核心思想

BPR 不预测评分,而是学习排序关系

对于用户 u:
- 物品 i (有交互)应该排在 物品 j (无交互)之前
- p(i >_u j) = σ(P_u · (Q_i - Q_j))

损失函数

# 伪代码
for user in users:
    positive_item = random.choice(user.interacted_items)
    negative_item = random.choice(all_items - user.interacted_items)
    
    score_pos = dot(user_vector, item_vector[positive_item])
    score_neg = dot(user_vector, item_vector[negative_item])
    
    loss = -log(sigmoid(score_pos - score_neg))

数学表示

L = -Σ log(σ(r̂_ui - r̂_uj)) + λ(||P_u||^2 + ||Q_i||^2 + ||Q_j||^2)

σ(x) = 1 / (1 + e^(-x))  (Sigmoid 函数)
λ: 正则化系数

BPR 训练步骤

让我们看 Gorse 中的实现:

func (bpr *BPR) Fit(ctx context.Context, trainSet, valSet dataset.CFSplit, config *FitConfig) Score {
    // 1. 初始化用户和物品向量(随机初始化)
    bpr.Init(trainSet)
    
    // 2. 迭代训练
    for epoch := 1; epoch <= bpr.nEpochs; epoch++ {
        // 并行训练(每个 worker 处理一批样本)
        parallel.Parallel(trainSet.CountFeedback(), config.Jobs, func(workerId, _ int) error {
            // 2.1 随机选择一个用户(有交互历史的)
            userIndex := random.choice(users with feedback)
            
            // 2.2 随机选择一个正样本(用户交互过的物品)
            posIndex := random.choice(user's interacted items)
            
            // 2.3 随机选择一个负样本(用户未交互的物品)
            negIndex := random.choice(items - user's interacted items)
            
            // 2.4 计算预测分数差
            diff = predict(user, posIndex) - predict(user, negIndex)
            // diff = P_u · Q_i - P_u · Q_j
            
            // 2.5 计算损失
            loss = log(1 + exp(-diff))
            
            // 2.6 计算梯度
            grad = exp(-diff) / (1 + exp(-diff))
            
            // 2.7 更新参数(梯度下降)
            // ∂L/∂Q_i = -grad * P_u - reg * Q_i
            // ∂L/∂Q_j = grad * P_u - reg * Q_j  
            // ∂L/∂P_u = -grad * (Q_i - Q_j) - reg * P_u
            
            Q_i += lr * (grad * P_u - reg * Q_i)
            Q_j += lr * (-grad * P_u - reg * Q_j)
            P_u += lr * (grad * (Q_i - Q_j) - reg * P_u)
            
            return nil
        })
        
        // 2.8 定期评估(每 10 个 epoch)
        if epoch % config.Verbose == 0 {
            score = Evaluate(bpr, valSet, trainSet, topK, ...)
            log.Info("NDCG@10", score.NDCG)
        }
    }
    
    return score
}

BPR 代码解析

1. 初始化阶段

func (bpr *BPR) Init(trainSet dataset.CFSplit) {
    // 随机初始化用户向量(均值 0,标准差 0.001)
    bpr.UserFactor = randomNormalMatrix(
        numUsers, 
        nFactors,      // 隐向量维度(默认 16)
        initMean,      // 均值(默认 0)
        initStdDev,    // 标准差(默认 0.001)
    )
    
    // 随机初始化物品向量
    bpr.ItemFactor = randomNormalMatrix(
        numItems, 
        nFactors, 
        initMean, 
        initStdDev,
    )
    
    // 标记哪些用户/物品有训练数据
    for userIndex, feedback := range trainSet.GetUserFeedback() {
        if len(feedback) > 0 {
            bpr.UserPredictable.Set(userIndex)
        }
    }
}

2. 核心训练循环

// 2.1 选择用户
var userIndex int32
for {
    userIndex = random.Int31n(trainSet.CountUsers())
    if len(trainSet.GetUserFeedback()[userIndex]) > 0 {
        break  // 确保用户有交互历史
    }
}

// 2.2 选择正样本
ratingCount := len(trainSet.GetUserFeedback()[userIndex])
posIndex := trainSet.GetUserFeedback()[userIndex][random.Intn(ratingCount)]

// 2.3 选择负样本(不在用户交互集中)
var negIndex int32
for {
    temp := random.Int31n(trainSet.CountItems())
    if !userFeedback[userIndex].Contains(temp) {
        negIndex = temp
        break
    }
}

3. 梯度计算和参数更新

// 预测分数差
diff := bpr.internalPredict(userIndex, posIndex) - 
        bpr.internalPredict(userIndex, negIndex)
// diff = P_u · Q_i - P_u · Q_j

// 损失
cost[workerId] += log1p(exp(-diff))

// Sigmoid 梯度
grad := exp(-diff) / (1.0 + exp(-diff))

// 备份当前参数
copy(userFactor[workerId], bpr.UserFactor[userIndex])
copy(positiveItemFactor[workerId], bpr.ItemFactor[posIndex])
copy(negativeItemFactor[workerId], bpr.ItemFactor[negIndex])

// 更新正样本物品向量
// Q_i = Q_i + lr * (grad * P_u - reg * Q_i)
temp = grad * userFactor
temp = temp - reg * positiveItemFactor
bpr.ItemFactor[posIndex] += lr * temp

// 更新负样本物品向量
// Q_j = Q_j + lr * (-grad * P_u - reg * Q_j)
temp = -grad * userFactor
temp = temp - reg * negativeItemFactor
bpr.ItemFactor[negIndex] += lr * temp

// 更新用户向量
// P_u = P_u + lr * (grad * (Q_i - Q_j) - reg * P_u)
temp = positiveItemFactor - negativeItemFactor
temp = grad * temp
temp = temp - reg * userFactor
bpr.UserFactor[userIndex] += lr * temp

BPR 超参数

type BPR struct {
    nFactors   int      // 隐向量维度(默认 16)
    nEpochs    int      // 训练轮数(默认 100)
    lr         float32  // 学习率(默认 0.05)
    reg        float32  // 正则化系数(默认 0.01)
    initMean   float32  // 初始化均值(默认 0)
    initStdDev float32  // 初始化标准差(默认 0.001)
}

调优建议

参数 默认值 调优范围 说明
n_factors 16 8-128 越大越精确,但训练慢
n_epochs 100 50-500 根据收敛情况调整
lr 0.05 0.001-0.1 过大不收敛,过小收敛慢
reg 0.01 0.001-0.1 防止过拟合
init_std_dev 0.001 0.0001-0.01 影响训练稳定性

ALS 算法详解

核心思想

ALS 通过交替固定用户向量或物品向量,优化另一组向量:

迭代 1: 固定物品向量 Q,优化用户向量 P
迭代 2: 固定用户向量 P,优化物品向量 Q
迭代 3: 固定物品向量 Q,优化用户向量 P
...

每一步都有封闭解(Closed-form Solution),无需梯度下降!

损失函数

L = Σ c_ui (r_ui - P_u · Q_i)^2 + λ(||P||^2 + ||Q||^2)

c_ui: 置信度(1 + α * r_ui)
α: 权重参数(默认 0.001)
λ: 正则化系数(默认 0.06)

ALS 优化公式

对于用户 u,固定 Q 后,P_u 的最优解:

P_u = (Q^T C^u Q + λI)^(-1) Q^T C^u r_u

C^u: 对角矩阵,对角线是 c_ui
r_u: 用户 u 的评分向量

ALS 训练步骤

func (als *ALS) Fit(ctx context.Context, trainSet, valSet dataset.CFSplit, config *FitConfig) Score {
    // 1. 初始化
    als.Init(trainSet)
    
    for epoch := 1; epoch <= als.nEpochs; epoch++ {
        // ==================== 第一步:更新用户向量 ====================
        
        // 1.1 计算 S^q = Σ Q_i Q_i^T(物品向量的格拉姆矩阵)
        S_q := zeros(nFactors, nFactors)
        for itemIndex := 0; itemIndex < trainSet.CountItems(); itemIndex++ {
            if len(trainSet.GetItemFeedback()[itemIndex]) > 0 {
                for i := 0; i < nFactors; i++ {
                    for j := 0; j < nFactors; j++ {
                        S_q[i][j] += Q[itemIndex][i] * Q[itemIndex][j]
                    }
                }
            }
        }
        
        // 1.2 并行更新每个用户
        parallel.Parallel(trainSet.CountUsers(), config.Jobs, func(workerId, userIndex int) {
            userFeedback := trainSet.GetUserFeedback()[userIndex]
            
            // 对每个隐因子 f
            for f := 0; f < nFactors; f++ {
                // 计算用户向量的第 f 维
                a, b, c := 0.0, 0.0, 0.0
                
                for _, itemIndex := range userFeedback {
                    // a = Σ (1 - (1-α) * residual) * Q[i][f]
                    a += (1 - (1-weight)*residual[itemIndex]) * Q[itemIndex][f]
                    
                    // c = Σ (1-α) * Q[i][f]^2
                    c += (1 - weight) * Q[itemIndex][f] * Q[itemIndex][f]
                }
                
                // b = α * Σ P_u[k] * S_q[k][f]  (k≠f)
                for k := 0; k < nFactors; k++ {
                    if k != f {
                        b += weight * P[userIndex][k] * S_q[k][f]
                    }
                }
                
                // 更新
                P[userIndex][f] = (a - b) / (c + weight*S_q[f][f] + reg)
            }
        })
        
        // ==================== 第二步:更新物品向量 ====================
        
        // 2.1 计算 S^p = Σ P_u P_u^T(用户向量的格拉姆矩阵)
        S_p := zeros(nFactors, nFactors)
        for userIndex := 0; userIndex < trainSet.CountUsers(); userIndex++ {
            if len(trainSet.GetUserFeedback()[userIndex]) > 0 {
                for i := 0; i < nFactors; i++ {
                    for j := 0; j < nFactors; j++ {
                        S_p[i][j] += P[userIndex][i] * P[userIndex][j]
                    }
                }
            }
        }
        
        // 2.2 并行更新每个物品(类似用户更新)
        parallel.Parallel(trainSet.CountItems(), config.Jobs, func(workerId, itemIndex int) {
            // ... 类似用户更新逻辑
        })
        
        // 2.3 评估
        if epoch % config.Verbose == 0 {
            score = Evaluate(als, valSet, trainSet, topK, ...)
            log.Info("NDCG@10", score.NDCG)
        }
    }
    
    return score
}

ALS 代码解析

格拉姆矩阵(Gram Matrix)

// S^q = Σ q_i q_i^T
// 这是一个 K×K 的对称矩阵
floats.MatZero(s)
for itemIndex := 0; itemIndex < trainSet.CountItems(); itemIndex++ {
    if len(trainSet.GetItemFeedback()[itemIndex]) > 0 {
        for i := 0; i < als.nFactors; i++ {
            for j := 0; j < als.nFactors; j++ {
                s[i][j] += als.ItemFactor[itemIndex][i] * 
                           als.ItemFactor[itemIndex][j]
            }
        }
    }
}

这是所有物品向量的协方差矩阵,用于快速计算用户向量更新。

逐维优化

for f := 0; f < als.nFactors; f++ {
    // 计算残差(去掉当前维度的影响)
    for _, i := range userFeedback {
        userRes[workerId][i] = userPredictions[workerId][i] - 
                               als.UserFactor[userIndex][f] * 
                               als.ItemFactor[i][f]
    }
    
    // 计算 a, b, c
    a, b, c := float32(0), float32(0), float32(0)
    for _, i := range userFeedback {
        a += (1 - (1-als.weight)*userRes[workerId][i]) * 
             als.ItemFactor[i][f]
        c += (1 - als.weight) * 
             als.ItemFactor[i][f] * 
             als.ItemFactor[i][f]
    }
    for k := 0; k < als.nFactors; k++ {
        if k != f {
            b += als.weight * als.UserFactor[userIndex][k] * s[k][f]
        }
    }
    
    // 更新第 f 维
    als.UserFactor[userIndex][f] = (a - b) / 
                                     (c + als.weight*s[f][f] + als.reg)
    
    // 更新预测(加回当前维度的影响)
    for _, i := range userFeedback {
        userPredictions[workerId][i] = userRes[workerId][i] + 
                                        als.UserFactor[userIndex][f] * 
                                        als.ItemFactor[i][f]
    }
}

ALS 超参数

type ALS struct {
    nFactors   int      // 隐向量维度(默认 16)
    nEpochs    int      // 训练轮数(默认 50)
    reg        float32  // 正则化系数(默认 0.06)
    initMean   float32  // 初始化均值(默认 0)
    initStdDev float32  // 初始化标准差(默认 0.1)
    weight     float32  // 负样本权重(默认 0.001)
}

调优建议

参数 默认值 调优范围 说明
n_factors 16 8-128 越大越精确
n_epochs 50 20-200 ALS 收敛较快
reg 0.06 0.001-0.1 防止过拟合
weight (α) 0.001 0.0001-0.01 负样本权重
init_std_dev 0.1 0.01-0.5 比 BPR 大

训练流程

Master 中的训练流程

func (m *Master) trainCollaborativeFiltering(trainSet, testSet dataset.CFSplit) error {
    // 1. 数据检查
    if trainSet.CountUsers() == 0 || trainSet.CountItems() == 0 || trainSet.CountFeedback() == 0 {
        return errors.New("No data found")
    }
    
    // 2. 检查数据是否变化
    if trainSet.CountFeedback() == m.collaborativeFilteringTrainSetSize {
        log.Info("collaborative filtering dataset not changed")
        return nil  // 数据未变化,跳过训练
    }
    
    // 3. 选择模型(BPR 或 ALS)
    m.collaborativeFilteringModelMutex.Lock()
    modelType := m.collaborativeFilteringMeta.Type  // "BPR" or "ALS"
    params := m.collaborativeFilteringMeta.Params
    
    // 如果超参数优化找到了更好的模型,使用新参数
    if m.collaborativeFilteringTarget.Score.NDCG > m.collaborativeFilteringMeta.Score.NDCG {
        modelType = m.collaborativeFilteringTarget.Type
        params = m.collaborativeFilteringTarget.Params
        log.Info("find better collaborative filtering model", 
                 zap.Any("score", m.collaborativeFilteringTarget.Score))
    }
    m.collaborativeFilteringModelMutex.Unlock()
    
    // 4. 创建模型并训练
    model := m.newCollaborativeFilteringModel(modelType, params)
    score := model.Fit(ctx, trainSet, testSet,
        cf.NewFitConfig().
            SetJobs(m.Config.Master.NumJobs).
            SetPatience(m.Config.Recommend.Collaborative.EarlyStopping.Patience))
    
    log.Info("fit collaborative filtering model completed",
             zap.Float32("NDCG@10", score.NDCG),
             zap.Float32("Recall@10", score.Recall),
             zap.Float32("Precision@10", score.Precision))
    
    // 5. 构建物品向量索引(HNSW)
    matrixFactorizationItems := logics.NewMatrixFactorizationItems(time.Now())
    parallel.For(trainSet.CountItems(), m.Config.Master.NumJobs, func(i int) {
        if itemId, ok := trainSet.GetItemDict().String(int32(i)); ok && 
           model.IsItemPredictable(int32(i)) {
            matrixFactorizationItems.Add(itemId, model.GetItemFactor(int32(i)))
        }
    })
    
    // 6. 提取用户向量
    matrixFactorizationUsers := logics.NewMatrixFactorizationUsers()
    for i := 0; i < trainSet.CountUsers(); i++ {
        if userId, ok := trainSet.GetUserDict().String(int32(i)); ok && 
           model.IsUserPredictable(int32(i)) {
            matrixFactorizationUsers.Add(userId, model.GetUserFactor(int32(i)))
        }
    }
    
    // 7. 上传模型到 Blob Store
    modelId := time.Now().Unix()
    w, done, err := m.blobStore.Create(strconv.Itoa(modelId))
    err = matrixFactorizationItems.Marshal(w)   // 保存物品向量 + HNSW 索引
    err = matrixFactorizationUsers.Marshal(w)   // 保存用户向量
    w.Close()
    <-done
    
    // 8. 更新元数据
    m.collaborativeFilteringMeta.ID = modelId
    m.collaborativeFilteringMeta.Type = modelType
    m.collaborativeFilteringMeta.Params = params
    m.collaborativeFilteringMeta.Score = score
    err = m.metaStore.Put(meta.COLLABORATIVE_FILTERING_MODEL, 
                          m.collaborativeFilteringMeta.ToJSON())
    
    // 9. 更新 Prometheus 指标
    CollaborativeFilteringNDCG10.Set(float64(score.NDCG))
    CollaborativeFilteringRecall10.Set(float64(score.Recall))
    CollaborativeFilteringPrecision10.Set(float64(score.Precision))
    
    return nil
}

训练流程图

开始训练协同过滤模型
    ↓
检查数据(用户、物品、反馈)
    ↓
数据未变化? → Yes → 跳过训练
    ↓ No
选择模型类型(BPR or ALS)
    ↓
检查是否有更好的超参数
    ↓
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
      模型训练(Fit)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
    ↓
初始化用户/物品向量
    ↓
┌──────────────────────────┐
│  迭代训练(100 轮)        │
│  ├─ 采样三元组 (u,i,j)    │
│  ├─ 计算损失              │
│  ├─ 更新梯度              │
│  └─ 定期评估 (NDCG)       │
└──────────────────────────┘
    ↓
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
    ↓
构建 HNSW 索引(物品向量)
    ↓
提取用户向量
    ↓
上传到 Blob Store
  ├─ matrixFactorizationItems (物品向量 + HNSW)
  └─ matrixFactorizationUsers (用户向量)
    ↓
更新元数据(模型 ID、类型、参数、分数)
    ↓
更新 Prometheus 指标
    ↓
完成

超参数调优

自动超参数优化

Gorse 使用 Optuna 进行自动超参数搜索(贝叶斯优化):

func (m *Master) optimizeCollaborativeFiltering(trainSet, testSet dataset.CFSplit) error {
    // 1. 检查是否需要优化
    if m.Config.Recommend.Collaborative.OptimizeTrials <= 0 {
        return nil  // 未启用优化
    }
    
    // 2. 创建优化器
    study, err := goptuna.CreateStudy("optimizeCollaborativeFiltering",
        goptuna.StudyOptionDirection(goptuna.StudyDirectionMaximize),  // 最大化 NDCG
        goptuna.StudyOptionSampler(tpe.NewSampler()),                  // TPE 采样器
        goptuna.StudyOptionLogger(log.NewOptunaLogger(log.Logger())))
    
    // 3. 优化目标函数
    objective := func(trial goptuna.Trial) (float64, error) {
        // 3.1 建议超参数
        modelType, _ := trial.SuggestCategorical("model_type", []string{"BPR", "ALS"})
        
        var params model.Params
        if modelType == "BPR" {
            params = cf.NewBPR(nil).SuggestParams(trial)
            // 自动建议:
            // - lr: [0.001, 0.1] (对数分布)
            // - reg: [0.001, 0.1] (对数分布)
            // - init_std_dev: [0.001, 0.1] (对数分布)
        } else {
            params = cf.NewALS(nil).SuggestParams(trial)
            // 自动建议:
            // - reg: [0.001, 0.1]
            // - weight: [0.0001, 0.01]
            // - init_std_dev: [0.001, 0.1]
        }
        
        // 3.2 训练模型
        m := m.newCollaborativeFilteringModel(modelType, params)
        score := m.Fit(ctx, trainSet, testSet, 
            cf.NewFitConfig().
                SetJobs(m.Config.Master.NumJobs).
                SetPatience(5))  // 早停
        
        // 3.3 返回 NDCG 作为优化目标
        return float64(score.NDCG), nil
    }
    
    // 4. 运行优化(20 次试验)
    err = study.Optimize(objective, m.Config.Recommend.Collaborative.OptimizeTrials)
    
    // 5. 获取最佳参数
    bestParams := study.BestParams()
    bestScore := study.BestValue()
    
    // 6. 更新目标模型
    m.collaborativeFilteringModelMutex.Lock()
    m.collaborativeFilteringTarget.Type = modelType
    m.collaborativeFilteringTarget.Params = bestParams
    m.collaborativeFilteringTarget.Score.NDCG = float32(bestScore)
    m.collaborativeFilteringModelMutex.Unlock()
    
    log.Info("found best collaborative filtering model",
             zap.String("type", modelType),
             zap.Any("params", bestParams),
             zap.Float64("NDCG", bestScore))
    
    return nil
}

超参数搜索空间

BPR

func (bpr *BPR) SuggestParams(trial goptuna.Trial) model.Params {
    return model.Params{
        model.NFactors:   16,  // 固定为 16
        model.Lr:         trial.SuggestLogFloat("Lr", 0.001, 0.1),
        model.Reg:        trial.SuggestLogFloat("Reg", 0.001, 0.1),
        model.InitMean:   0,   // 固定为 0
        model.InitStdDev: trial.SuggestLogFloat("InitStdDev", 0.001, 0.1),
    }
}

ALS

func (als *ALS) SuggestParams(trial goptuna.Trial) model.Params {
    return model.Params{
        model.NFactors:   16,  // 固定为 16
        model.InitMean:   0,
        model.InitStdDev: trial.SuggestLogFloat("InitStdDev", 0.001, 0.1),
        model.Reg:        trial.SuggestLogFloat("Reg", 0.001, 0.1),
        model.Alpha:      trial.SuggestLogFloat("Alpha", 0.001, 0.1),  // weight
    }
}

配置超参数优化

[recommend.collaborative]
# 启用超参数优化(20 次试验)
optimize_trials = 20

# 早停策略(5 轮不改进则停止)
[recommend.collaborative.early_stopping]
patience = 5

# 训练周期(每 3 小时训练一次)
fit_period = "3h"

模型评估

评估指标

Gorse 使用三个指标评估协同过滤模型:

指标 全称 说明 取值范围
NDCG Normalized Discounted Cumulative Gain 考虑排序位置的归一化增益 [0, 1]
Precision Precision@K 前 K 个推荐中相关物品的比例 [0, 1]
Recall Recall@K 所有相关物品中被推荐的比例 [0, 1]

NDCG@K 计算

func NDCG(targetSet mapset.Set[int32], rankList []int32, k int) float32 {
    if len(rankList) > k {
        rankList = rankList[:k]
    }
    
    // DCG = Σ (rel_i / log2(i+1))
    dcg := float32(0)
    for i, itemIndex := range rankList {
        if targetSet.Contains(itemIndex) {
            dcg += 1.0 / math32.Log2(float32(i+2))  // i+2 because i starts from 0
        }
    }
    
    // IDCG = 理想情况下的 DCG(所有相关物品排在最前面)
    idcg := float32(0)
    for i := 0; i < min(targetSet.Cardinality(), k); i++ {
        idcg += 1.0 / math32.Log2(float32(i+2))
    }
    
    if idcg == 0 {
        return 0
    }
    
    // NDCG = DCG / IDCG
    return dcg / idcg
}

示例

假设用户真实喜欢的物品是 {1, 3, 5}
模型推荐 Top-5: [3, 2, 1, 4, 5]

DCG = 1/log2(2) + 0 + 1/log2(4) + 0 + 1/log2(6)
    = 1/1 + 0 + 1/2 + 0 + 1/2.58
    = 1 + 0.5 + 0.39
    = 1.89

IDCG = 1/log2(2) + 1/log2(3) + 1/log2(4)
     = 1 + 0.63 + 0.5
     = 2.13

NDCG = 1.89 / 2.13 = 0.887

Precision@K 和 Recall@K

func Precision(targetSet mapset.Set[int32], rankList []int32, k int) float32 {
    if len(rankList) > k {
        rankList = rankList[:k]
    }
    
    hit := 0
    for _, itemIndex := range rankList {
        if targetSet.Contains(itemIndex) {
            hit++
        }
    }
    
    // Precision = 命中数 / K
    return float32(hit) / float32(len(rankList))
}

func Recall(targetSet mapset.Set[int32], rankList []int32, k int) float32 {
    if len(rankList) > k {
        rankList = rankList[:k]
    }
    
    hit := 0
    for _, itemIndex := range rankList {
        if targetSet.Contains(itemIndex) {
            hit++
        }
    }
    
    // Recall = 命中数 / 真实相关物品数
    return float32(hit) / float32(targetSet.Cardinality())
}

示例

用户真实喜欢: {1, 3, 5}  (3 个物品)
推荐 Top-5: [3, 2, 1, 4, 5]
命中: {1, 3, 5}  (3 个)

Precision@5 = 3 / 5 = 0.6
Recall@5 = 3 / 3 = 1.0

评估流程

func Evaluate(model cf.Model, testSet, trainSet dataset.CFSplit, k, candidates, jobs int) []float32 {
    // 1. 为每个测试用户生成推荐
    scores := make([][]float32, 3)  // NDCG, Precision, Recall
    
    parallel.Parallel(testSet.CountUsers(), jobs, func(workerId, userIndex int) {
        // 1.1 获取测试集中的真实交互
        targetSet := testSet.GetUserFeedback()[userIndex]
        if len(targetSet) == 0 {
            return  // 跳过无测试数据的用户
        }
        
        // 1.2 生成推荐列表(从候选集中)
        rankList := make([]int32, 0, candidates)
        for itemIndex := 0; itemIndex < trainSet.CountItems(); itemIndex++ {
            // 跳过训练集中已交互的物品
            if !trainSet.GetUserFeedback()[userIndex].Contains(itemIndex) {
                score := model.internalPredict(userIndex, itemIndex)
                rankList = append(rankList, itemIndex, score)
            }
        }
        
        // 1.3 排序(按分数降序)
        sort.Slice(rankList, func(i, j int) bool {
            return rankList[i].score > rankList[j].score
        })
        
        // 1.4 计算指标
        scores[0] += NDCG(targetSet, rankList, k)
        scores[1] += Precision(targetSet, rankList, k)
        scores[2] += Recall(targetSet, rankList, k)
    })
    
    // 2. 平均
    numUsers := testSet.CountUsersWithFeedback()
    return []float32{
        scores[0] / float32(numUsers),
        scores[1] / float32(numUsers),
        scores[2] / float32(numUsers),
    }
}

实战示例

示例 1:手动训练 BPR 模型

package main

import (
    "context"
    "github.com/gorse-io/gorse/dataset"
    "github.com/gorse-io/gorse/model"
    "github.com/gorse-io/gorse/model/cf"
)

func main() {
    // 1. 加载数据
    data := dataset.NewDataset(time.Now(), 1000, 5000)
    // 添加用户-物品交互...
    trainSet, testSet := data.SplitCF(0.2, 0)
    
    // 2. 创建 BPR 模型
    params := model.Params{
        model.NFactors:   16,
        model.Lr:         0.05,
        model.Reg:        0.01,
        model.NEpochs:    100,
        model.InitMean:   0,
        model.InitStdDev: 0.001,
    }
    bpr := cf.NewBPR(params)
    
    // 3. 训练
    config := cf.NewFitConfig().
        SetJobs(4).
        SetVerbose(10).
        SetPatience(5)
    
    score := bpr.Fit(context.Background(), trainSet, testSet, config)
    
    // 4. 查看结果
    fmt.Printf("NDCG@10: %.4f\n", score.NDCG)
    fmt.Printf("Precision@10: %.4f\n", score.Precision)
    fmt.Printf("Recall@10: %.4f\n", score.Recall)
    
    // 5. 预测
    userVector := bpr.GetUserFactor(0)  // 用户 0 的向量
    itemVector := bpr.GetItemFactor(10) // 物品 10 的向量
    score := floats.Dot(userVector, itemVector)
    fmt.Printf("预测评分: %.4f\n", score)
}

示例 2:使用配置文件训练

# config.toml

[recommend.collaborative]
# 模型类型(BPR 或 ALS)
model = "BPR"

# 训练周期
fit_period = "3h"

# 超参数
[recommend.collaborative.params]
n_factors = 16
n_epochs = 100
lr = 0.05
reg = 0.01
init_mean = 0.0
init_std_dev = 0.001

# 自动超参数优化(20 次试验)
[recommend.collaborative]
optimize_trials = 20

# 早停策略
[recommend.collaborative.early_stopping]
patience = 5

示例 3:查看训练进度

# 启动 Master
gorse-master --config config.toml

# 查看日志
tail -f /var/log/gorse/master.log

# 输出示例:
# [INFO] fit bpr 10/100 NDCG@10=0.3521 Precision@10=0.2134 Recall@10=0.1892
# [INFO] fit bpr 20/100 NDCG@10=0.4123 Precision@10=0.2567 Recall@10=0.2341
# [INFO] fit bpr 30/100 NDCG@10=0.4567 Precision@10=0.2891 Recall@10=0.2678
# ...
# [INFO] fit bpr complete NDCG@10=0.5123 Precision@10=0.3456 Recall@10=0.3234

示例 4:Prometheus 监控

# 查看训练指标
curl http://master-host:8086/metrics | grep collaborative

# 输出示例:
# gorse_master_collaborative_filtering_fit_seconds 125.5
# gorse_master_collaborative_filtering_ndcg_10 0.5123
# gorse_master_collaborative_filtering_precision_10 0.3456
# gorse_master_collaborative_filtering_recall_10 0.3234

示例 5:对比 BPR vs ALS

// 训练 BPR
bpr := cf.NewBPR(model.Params{
    model.NFactors: 16,
    model.Lr:       0.05,
    model.Reg:      0.01,
    model.NEpochs:  100,
})
bprScore := bpr.Fit(ctx, trainSet, testSet, config)

// 训练 ALS
als := cf.NewALS(model.Params{
    model.NFactors: 16,
    model.Reg:      0.06,
    model.Alpha:    0.001,
    model.NEpochs:  50,
})
alsScore := als.Fit(ctx, trainSet, testSet, config)

// 对比
fmt.Printf("BPR NDCG: %.4f, ALS NDCG: %.4f\n", bprScore.NDCG, alsScore.NDCG)
fmt.Printf("BPR Recall: %.4f, ALS Recall: %.4f\n", bprScore.Recall, alsScore.Recall)

总结:协同过滤训练要点

1. 核心概念

概念 说明
矩阵分解 将稀疏的用户-物品矩阵分解为两个低维稠密矩阵
隐向量 用户/物品的 K 维向量,捕捉潜在特征
BPR 通过成对比较学习排序(适合 Top-N 推荐)
ALS 交替优化用户和物品向量(收敛快)

2. 训练流程

加载数据
    ↓
划分训练集/测试集
    ↓
初始化用户/物品向量(随机)
    ↓
迭代训练(100 轮)
    ├─ BPR: 采样三元组 (u,i,j),SGD 更新
    └─ ALS: 交替优化用户/物品向量
    ↓
定期评估(NDCG, Precision, Recall)
    ↓
构建 HNSW 索引(加速搜索)
    ↓
保存模型

3. 关键参数

参数 推荐值 影响
n_factors 16-32 模型容量
n_epochs 50-200 训练时间
lr (BPR) 0.01-0.1 收敛速度
reg 0.001-0.1 过拟合控制

4. 性能优化

  • 并行训练:使用多核 CPU(jobs=8
  • 早停策略:防止过拟合(patience=5
  • 超参数优化:自动搜索最佳参数(optimize_trials=20
  • HNSW 索引:加速推荐生成(O(log N) 复杂度)

5. 实战建议

  1. 小数据集:BPR + 16 维 + 100 轮
  2. 大数据集:ALS + 32 维 + 50 轮
  3. 冷启动严重:增加 init_std_dev
  4. 过拟合:增加 reg 或减少 n_factors
  5. 收敛慢:调整 lr 或启用早停
posted @ 2026-01-19 11:16  技术漫游  阅读(0)  评论(0)    收藏  举报