X 推荐算法列表式重评分机制详解
前言
列表式重评分(Listwise Rescoring)是 X 推荐系统中一个重要的分数修正机制。与传统的点式(Pointwise)或对式(Pairwise)评分不同,列表式重评分考虑整个候选列表的上下文,根据候选在组内的位置来调整分数,从而确保推荐结果的多样性。
一、列表式重评分概述
1.1 什么是列表式重评分?
列表式重评分是一种基于位置的分数调整机制:
- 分组:将候选推文按照某个键(Key)分组(如作者、服务类型等)
- 排序:在每个组内按分数降序排序
- 重评分:根据候选在组内的位置(索引)应用重评分因子
1.2 为什么需要列表式重评分?
问题:如果只使用模型分数排序,可能会出现:
- 同一个作者的多个推文连续出现
- 同一类型的推文过度集中
- 缺乏内容多样性
解决方案 :列表式重评分通过位置衰减机制,降低同一组内靠后候选的分数,从而:
- 确保作者多样性
- 确保内容类型多样性
- 提升用户体验
1.3 核心概念
候选列表
↓
按 Key 分组(如作者ID)
↓
组内按分数排序
↓
根据位置应用重评分因子
↓
更新分数
二、列表式重评分框架
2.1 核心 Trait
scala
trait ListwiseRescoringProvider[C <: CandidateWithFeatures[TweetCandidate], K] {
/**
* 定义分组键(Key)
* 例如:作者ID、服务类型等
*/
def groupByKey(candidate: C): Option[K]
/**
* 根据候选在组内的位置计算重评分因子
* @param index: 候选在组内的位置(0-based,按分数降序)
*/
def candidateRescoringFactor(
query: PipelineQuery,
candidate: C,
index: Int
): Double
/**
* 应用列表式重评分
* 返回:Map[推文ID, 重评分因子]
*/
def apply(
query: PipelineQuery,
candidates: Seq[C]
): Map[Long, Double] = {
candidates
.groupBy(groupByKey) // 按 Key 分组
.flatMap {
case (Some(_), groupedCandidates) =>
// 组内按分数降序排序
val sortedCandidates = groupedCandidates
.sortBy(_.features.getOrElse(ScoreFeature, None).getOrElse(0.0))(
Ordering.Double.reverse
)
// 根据位置计算重评分因子
sortedCandidates.zipWithIndex.map {
case (candidate, index) =>
candidate.candidate.id -> candidateRescoringFactor(query, candidate, index)
}
case _ => Map.empty
}
}
}
2.2 工作流程
1. 输入:候选推文列表
↓
2. 按 groupByKey 分组
例如:按作者ID分组
{
author1: [tweet1, tweet2, tweet3],
author2: [tweet4, tweet5],
...
}
↓
3. 每个组内按分数降序排序
{
author1: [tweet1(10.0), tweet2(9.0), tweet3(8.0)],
author2: [tweet4(9.5), tweet5(8.5)],
...
}
↓
4. 根据位置计算重评分因子
{
author1: {
tweet1: factor(0) = 1.0, // 第1个,不衰减
tweet2: factor(1) = 0.8, // 第2个,衰减20%
tweet3: factor(2) = 0.64, // 第3个,衰减36%
},
author2: {
tweet4: factor(0) = 1.0,
tweet5: factor(1) = 0.8,
}
}
↓
5. 输出:Map[推文ID, 重评分因子]
2.3 在 HeuristicScorer 中的使用
scala
val rescorers = Seq(
// ... 其他重评分规则
RescoreListwise(AuthorBasedListwiseRescoringProvider(query, candidates)),
RescoreListwise(ContentExplorationListwiseRescoringProvider(query, candidates)),
// ... 更多列表式重评分
)
// RescoreListwise 的实现
case class RescoreListwise(listwiseRescoringMap: Map[Long, Double])
extends RescoringFactorProvider {
override def selector(...): Boolean =
listwiseRescoringMap.contains(candidate.candidate.id)
override def factor(...): Double =
listwiseRescoringMap(candidate.candidate.id)
}
使用方式:
scala
finalScore = currentScore × listwiseRescoringFactor
三、列表式重评分提供者详解
3.1 基于作者的重评分(AuthorBasedListwiseRescoringProvider)
3.1.1 目的
确保作者多样性,避免同一作者的多个推文连续出现。
3.1.2 分组键
scala
override def groupByKey(candidate: CandidateWithFeatures[TweetCandidate]): Option[Long] =
candidate.features.getOrElse(AuthorIdFeature, None)
分组依据:作者ID
3.1.3 重评分因子
scala
override def candidateRescoringFactor(
query: PipelineQuery,
candidate: CandidateWithFeatures[TweetCandidate],
index: Int
): Double = {
val isSmallFollowGraph =
query.features.get.getOrElse(SGSFollowedUsersFeature, Seq.empty).size <= MinFollowed
val decayFactor = if (isSmallFollowGraph) {
query.params(SmallFollowGraphAuthorDiversityDecayFactor)
} else {
query.params(AuthorDiversityDecayFactor)
}
val floor = if (isSmallFollowGraph) {
query.params(SmallFollowGraphAuthorDiversityFloor)
} else {
query.params(AuthorDiversityFloor)
}
authorDiversityBasedRescorer(index, decayFactor, floor)
}
公式:
factor = (1 - floor) × decayFactor^index + floor
其中:
- index: 候选在组内的位置(0-based)
- decayFactor: 衰减因子(通常 0.5-0.9)
- floor: 最低因子(通常 0.1-0.5)
3.1.4 实际示例
假设 decayFactor = 0.8, floor = 0.2:
| 位置 | 计算 | 因子 |
|---|---|---|
| 0 (第1个) | (1-0.2) × 0.8^0 + 0.2 = 0.8 × 1 + 0.2 |
1.0 |
| 1 (第2个) | (1-0.2) × 0.8^1 + 0.2 = 0.8 × 0.8 + 0.2 |
0.84 |
| 2 (第3个) | (1-0.2) × 0.8^2 + 0.2 = 0.8 × 0.64 + 0.2 |
0.712 |
| 3 (第4个) | (1-0.2) × 0.8^3 + 0.2 = 0.8 × 0.512 + 0.2 |
0.61 |
| ... | ... | ... |
| ∞ | (1-0.2) × 0 + 0.2 |
0.2 (floor) |
效果:
- 同一作者的第1个推文:不衰减(factor = 1.0)
- 同一作者的第2个推文:衰减16%(factor = 0.84)
- 同一作者的第3个推文:衰减28.8%(factor = 0.712)
- 同一作者的第4个推文:衰减39%(factor = 0.61)
- 后续推文:最低衰减到 floor(factor = 0.2)
3.1.5 小关注图特殊处理
如果用户关注数 ≤ 50,使用不同的参数:
SmallFollowGraphAuthorDiversityDecayFactorSmallFollowGraphAuthorDiversityFloor
原因:小关注图的用户可能更愿意看到同一作者的多个推文。
3.2 基于已印象作者的重评分(ImpressedAuthorDecayRescoringProvider)
3.2.1 目的
考虑用户已经看过的作者,进一步降低这些作者推文的分数。
3.2.2 核心机制
scala
def apply(...): Map[Long, Double] = {
// 1. 计算已印象作者的频率
val authorFreq = calculateAuthorImpressionFrequencies(query)
// 2. 分组并排序
candidates.groupBy(groupByKey).flatMap {
case (Some(authorId), groupedCandidates) =>
val sortedCandidates = groupedCandidates.sortBy(...)
sortedCandidates.zipWithIndex.map {
case (candidate, index) =>
// 3. 有效索引 = 组内位置 + 已印象次数
val effectiveIndex = index + authorFreq.getOrElse(authorId, 0)
// 4. 根据是否内部网络使用不同的衰减参数
val isInNetworkCandidate = candidate.features.getOrElse(InNetworkFeature, true)
val decayFactor = if (isInNetworkCandidate)
inNetworkDecayFactor
else
outNetworkDecayFactor
val floor = if (isInNetworkCandidate)
inNetworkFloor
else
outNetworkFloor
authorDiversityBasedRescorer(effectiveIndex, decayFactor, floor)
}
}
}
3.2.3 已印象频率计算
scala
private def calculateAuthorImpressionFrequencies(query: PipelineQuery): Map[Long, Int] = {
val impressedTweetIds = query.features
.map(_.getOrElse(ImpressedTweets, Seq.empty))
.getOrElse(Seq.empty)
.toSet
val servedAuthorMap = query.features
.map(_.get(ServedAuthorIdsFeature))
.getOrElse(Map.empty)
servedAuthorMap.map {
case (authorId, tweetIds) =>
val impressedCount = tweetIds.count(impressedTweetIds.contains)
authorId -> impressedCount
}.filter(_._2 > 0) // 只包含至少有一个已印象推文的作者
}
3.2.4 实际示例
假设:
- 作者A在组内是第1个(index = 0)
- 用户已经看过作者A的2个推文(authorFreq = 2)
decayFactor = 0.8,floor = 0.2
计算:
effectiveIndex = 0 + 2 = 2
factor = (1 - 0.2) × 0.8^2 + 0.2 = 0.8 × 0.64 + 0.2 = 0.712
效果:即使这是作者A在组内的第1个推文,但由于用户已经看过该作者的推文,有效索引变为2,分数衰减28.8%。
3.3 基于内容探索的重评分(ContentExplorationListwiseRescoringProvider)
3.3.1 目的
限制内容探索类型的推文数量,避免过度探索。
3.3.2 分组键
scala
override def groupByKey(candidate: CandidateWithFeatures[TweetCandidate]): Option[ServedType] =
Some(candidate.features.get(ServedTypeFeature))
分组依据:服务类型(ServedType)
3.3.3 重评分因子
scala
override def candidateRescoringFactor(
query: PipelineQuery,
candidate: CandidateWithFeatures[TweetCandidate],
index: Int
): Double = {
if (query.params(EnableContentExplorationCandidateMaxCountParam)) {
val servedType = candidate.features.get(ServedTypeFeature)
if (servedType == ServedType.ForYouUserInterestSummary ||
servedType == ServedType.ForYouContentExploration ||
servedType == ServedType.ForYouContentExplorationTier2 ||
servedType == ServedType.ForYouContentExplorationDeepRetrievalI2i ||
servedType == ServedType.ForYouContentExplorationTier2DeepRetrievalI2i) {
0.0001 // 几乎完全衰减
} else {
1.0
}
} else {
1.0
}
}
逻辑:
- 如果是内容探索类型的推文:factor = 0.0001(几乎完全衰减)
- 否则:factor = 1.0(不衰减)
注意 :这个实现不按位置衰减,而是对所有内容探索类型的推文应用相同的衰减因子。
3.4 基于深度检索的重评分(DeepRetrievalListwiseRescoringProvider)
3.4.1 目的
限制深度检索类型的推文数量。
3.4.2 重评分因子
scala
override def candidateRescoringFactor(
query: PipelineQuery,
candidate: CandidateWithFeatures[TweetCandidate],
index: Int
): Double = {
if (query.params(EnableDeepRetrievalMaxCountParam)) {
val servedType = candidate.features.get(ServedTypeFeature)
val maxCount = query.params(DeepRetrievalMaxCountParam)
if (servedType == ServedType.ForYouContentExplorationDeepRetrievalI2i && index >= maxCount) {
0.0001 // 超过最大数量后几乎完全衰减
} else {
1.0
}
} else {
1.0
}
}
逻辑:
- 如果是深度检索类型且位置 >= maxCount:factor = 0.0001
- 否则:factor = 1.0
示例:
maxCount = 3- 位置 0, 1, 2:factor = 1.0
- 位置 3, 4, ...:factor = 0.0001
3.5 基于候选源多样性的重评分(CandidateSourceDiversityListwiseRescoringProvider)
3.5.1 目的
确保候选源多样性,避免同一来源的推文过度集中。
3.5.2 分组键
scala
override def groupByKey(
candidate: CandidateWithFeatures[TweetCandidate]
): Option[(ServedType, Option[SourceSignal])] = {
val servedType = candidate.features.get(ServedTypeFeature)
val sourceSignalOpt = candidate.features.getOrElse(SourceSignalFeature, None)
Some((servedType, sourceSignalOpt))
}
分组依据:(服务类型, 源信号)
3.5.3 重评分因子
scala
override def candidateRescoringFactor(
query: PipelineQuery,
candidate: CandidateWithFeatures[TweetCandidate],
index: Int
): Double = {
candidate.features.get(ServedTypeFeature) match {
case ServedType.ForYouInNetwork => 1.0 // 内部网络不衰减
case _ =>
if (query.params(EnableCandidateSourceDiversityDecay)) {
val decayFactor = query.params(CandidateSourceDiversityDecayFactor)
val floor = query.params(CandidateSourceDiversityFloor)
candidateSourceDiversityRescorer(index, decayFactor, floor)
} else {
1.0
}
}
}
公式:
factor = (1 - floor) × decayFactor^index + floor
特殊处理:
- 内部网络推文(
ForYouInNetwork):不衰减(factor = 1.0) - 其他类型:按位置衰减
3.6 其他列表式重评分提供者
3.6.1 EvergreenDeepRetrievalListwiseRescoringProvider
类似 DeepRetrievalListwiseRescoringProvider,但针对常青深度检索类型。
3.6.2 EvergreenDeepRetrievalCrossBorderListwiseRescoringProvider
针对跨境常青深度检索类型。
3.6.3 ImpressedMediaClusterBasedListwiseRescoringProvider
基于已印象媒体聚类的重评分,降低已看过的媒体聚类内容的分数。
3.6.4 ImpressedImageClusterBasedListwiseRescoringProvider
基于已印象图片聚类的重评分,降低已看过的图片聚类内容的分数。
四、列表式重评分的数学原理
4.1 指数衰减公式
大多数列表式重评分使用指数衰减:
factor(index) = (1 - floor) × decayFactor^index + floor
其中:
- index: 候选在组内的位置(0-based)
- decayFactor: 衰减因子(0 < decayFactor < 1)
- floor: 最低因子(0 < floor < 1)
4.2 公式特性
- index = 0 :
factor = (1 - floor) × 1 + floor = 1.0(不衰减) - index → ∞ :
factor → floor(收敛到最低值) - 单调递减:位置越靠后,因子越小
- 平滑衰减:指数衰减比线性衰减更平滑
4.3 参数影响
| 参数 | 影响 | 示例 |
|---|---|---|
decayFactor 增大 |
衰减更慢 | 0.9 vs 0.5 |
decayFactor 减小 |
衰减更快 | 0.5 vs 0.9 |
floor 增大 |
最低值更高 | 0.5 vs 0.1 |
floor 减小 |
最低值更低 | 0.1 vs 0.5 |
4.4 衰减曲线示例
假设 decayFactor = 0.8, floor = 0.2:
factor
1.0 |●
| \
0.8 | \●
| \
0.6 | \●
| \
0.4 | \●
| \
0.2 |________\●___________ (floor)
| \
0.0 |___________\___________
0 1 2 3 4 5 ... index
五、列表式重评分的完整流程
5.1 在推荐 Pipeline 中的位置
候选生成
↓
特征提取
↓
模型评分(多模型融合)
↓
启发式重评分
├─ 点式重评分(RescoreOutOfNetwork, RescoreReplies, ...)
└─ 列表式重评分(AuthorBased, ContentExploration, ...)⭐
↓
Phoenix 重评分(可选)
↓
多样性重评分(可选)
↓
最终排序
5.2 列表式重评分的执行顺序
在 HeuristicScorer 中,列表式重评分在点式重评分之后执行:
scala
val rescorers = Seq(
RescoreOutOfNetwork, // 点式
RescoreReplies, // 点式
RescoreMTLNormalization(...), // 点式
RescoreListwise(AuthorBasedListwiseRescoringProvider(...)), // 列表式
RescoreListwise(ContentExplorationListwiseRescoringProvider(...)), // 列表式
// ... 更多列表式重评分
)
val scaleFactor = rescorers.map(_(query, candidate)).product
注意 :所有重评分因子是相乘的。
5.3 实际计算示例
假设一个推文:
步骤1:多模型融合
WeightedModelScore = 10.0
步骤2:点式重评分
RescoreOutOfNetwork = 0.8
RescoreReplies = 1.0
RescoreMTLNormalization = 0.95
Score = 10.0 × 0.8 × 1.0 × 0.95 = 7.6
步骤3:列表式重评分(基于作者)
假设:
-
这是作者A在组内的第2个推文(index = 1)
-
decayFactor = 0.8,floor = 0.2 -
factor = (1 - 0.2) × 0.8^1 + 0.2 = 0.84Score = 7.6 × 0.84 = 6.384
步骤4:其他列表式重评分
ContentExploration = 1.0
CandidateSourceDiversity = 0.9
Score = 6.384 × 1.0 × 0.9 = 5.746
最终分数 :5.746
六、列表式重评分的优势
6.1 确保多样性
- 作者多样性:避免同一作者连续出现
- 内容类型多样性:避免同一类型过度集中
- 来源多样性:确保不同来源的内容
6.2 考虑上下文
- 列表上下文:考虑整个候选列表
- 用户历史:考虑已印象的内容
- 位置感知:根据位置调整分数
6.3 灵活可配置
- 参数可调:衰减因子和最低值可配置
- 条件启用:可以根据条件启用/禁用
- 分组灵活:可以按不同键分组
6.4 平滑衰减
- 指数衰减:比硬性限制更平滑
- 有下限:不会完全衰减到0
- 可预测:衰减曲线可预测
七、列表式重评分的局限性
7.1 计算复杂度
- 分组排序:需要对每个组进行排序
- 多次遍历:需要多次遍历候选列表
- 内存开销:需要存储分组结果
7.2 参数调优
- 参数敏感:衰减因子和最低值需要仔细调优
- 场景依赖:不同场景可能需要不同参数
- A/B 测试:需要大量 A/B 测试找到最优参数
7.3 可能的问题
- 过度衰减:可能导致高质量内容被过度降低
- 位置偏差:可能对位置靠后的候选不公平
- 组间不平衡:不同组的大小可能差异很大
八、最佳实践
8.1 参数设置
-
衰减因子(decayFactor)
- 范围:0.5 - 0.9
- 较小值:衰减更快,多样性更强
- 较大值:衰减更慢,相关性更强
-
最低值(floor)
- 范围:0.1 - 0.5
- 较小值:允许更多多样性
- 较大值:保证最低相关性
8.2 分组策略
- 选择合适的键:根据业务目标选择分组键
- 避免过度分组:分组太细可能导致效果不明显
- 考虑组大小:确保每个组有足够的候选
8.3 监控指标
- 多样性指标:作者多样性、类型多样性
- 相关性指标:CTR、参与度
- 用户体验指标:满意度、留存率
九、总结
9.1 核心要点
- 列表式重评分:基于整个候选列表的上下文调整分数
- 分组排序:按键分组,组内按分数排序
- 位置衰减:根据位置应用指数衰减
- 多样性保证:确保推荐结果的多样性
9.2 关键公式
factor(index) = (1 - floor) × decayFactor^index + floor
最终分数 = 原始分数 × factor
9.3 主要提供者
- AuthorBasedListwiseRescoringProvider:作者多样性
- ImpressedAuthorDecayRescoringProvider:已印象作者衰减
- ContentExplorationListwiseRescoringProvider:内容探索限制
- DeepRetrievalListwiseRescoringProvider:深度检索限制
- CandidateSourceDiversityListwiseRescoringProvider:候选源多样性
9.4 设计理念
- 多样性优先:在保证相关性的同时确保多样性
- 位置感知:根据位置调整分数
- 平滑衰减:使用指数衰减而非硬性限制
- 灵活配置:参数可配置,易于调优
参考文件:
home-mixer/server/src/main/scala/com/twitter/home_mixer/product/scored_tweets/scorer/ListwiseRescoringProvider.scalahome-mixer/server/src/main/scala/com/twitter/home_mixer/product/scored_tweets/scorer/AuthorBasedListwiseRescoringProvider.scalahome-mixer/server/src/main/scala/com/twitter/home_mixer/product/scored_tweets/scorer/ContentExplorationListwiseRescoringProvider.scalahome-mixer/server/src/main/scala/com/twitter/home_mixer/product/scored_tweets/scorer/ImpressedAuthorDecayRescoringProvider.scala