Gorse 协同过滤模型训练详解
目录
协同过滤概述
什么是协同过滤?
协同过滤(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. 实战建议
- 小数据集:BPR + 16 维 + 100 轮
- 大数据集:ALS + 32 维 + 50 轮
- 冷启动严重:增加
init_std_dev - 过拟合:增加
reg或减少n_factors - 收敛慢:调整
lr或启用早停

浙公网安备 33010602011771号