一、为什么要做重排阶段?
大家平时刷新闻的时候,是不是经常遇到这种情况:点开一个App,首页刷下来全是同一个明星的八卦,或者连续几条都是类似“某地发生车祸”的报道。看第一条还有点兴趣,第二条第三条就彻底不想看了。这就是推荐系统只关注了点击率,忽略了用户感受的结果。
在推荐系统的流程里,召回阶段从海量文章里捞出一批候选,粗排和精排阶段用复杂的模型给每篇文章打分,分数高的排前面。但精排模型往往只关注单个物品与用户的匹配度,比如点击概率、停留时长等,很少考虑物品之间的相互关系。于是就会出现上面说的“同质化”问题。重排阶段就是补这个窟窿的——在精排结果的基础上,再次调整顺序,让列表整体更符合用户的真实体验。
重排不一定需要复杂的深度学习模型,很多时候用一些简单的规则或者算法就能明显改善效果。今天我们就来聊聊几种常见又实用的重排方法,全部用Python实现,代码里加了详细注释,方便大家直接拿来用。
二、重排阶段的常见痛点
做重排之前,得先知道用户到底在烦什么。根据用户反馈和数据分析,主要痛点有这几个:
- 内容同质化:连续N条新闻主题、来源、甚至标题风格都类似,用户很快就腻了。
- 旧闻反复推荐:有些新闻虽然点击率高,但已经过了一天甚至更久,用户早就看过了,再推就是浪费。
- 用户疲劳:同一个作者的新闻连续出现,或者同一类型的新闻刷屏,用户会产生抵触情绪。
- 利益冲突:广告插入生硬、置顶内容与用户兴趣不符,导致整体体验下降。
重排的优化目标就是解决这些痛点,让用户觉得“这个App懂我”,而不是“这个App在硬塞给我内容”。
三、用户体验优化的具体方法
下面介绍四种常见方法,每种都配有Python示例,假设我们已经有一个精排过的新闻列表,每个新闻是一个字典,包含id、标题、得分、发布时间、类别等字段。
3.1 基于MMR的多样性重排
MMR(最大边际相关性)是一种经典算法,目标是在相关性和多样性之间找平衡。它的核心思想是:每次从候选列表里选一个新闻加到结果里,选的时候既要考虑它本身的相关性得分,又要考虑它和已选新闻的相似度,相似度高的会被惩罚。
# 技术栈: Python 3.8+
import numpy as np
from sklearn.metrics.pairwise import cosine_similarity
def mmr_rerank(candidates, topk, lambda_param=0.5):
"""
MMR重排算法
:param candidates: list of dict, 每个元素包含 'id', 'score', 'embedding' (向量)
:param topk: 最终需要返回的数量
:param lambda_param: 控制多样性和相关性的权重, 0表示只考虑多样性, 1只考虑相关性
:return: list of dict, 重排后的列表
"""
if len(candidates) <= topk:
return candidates # 不够就原样返回
# 提取所有嵌入向量,用于计算相似度
embeddings = np.array([c['embedding'] for c in candidates])
# 计算候选列表两两之间的余弦相似度矩阵
sim_matrix = cosine_similarity(embeddings)
selected_indices = [] # 已选中的索引
candidate_indices = list(range(len(candidates))) # 待选的索引
# 第一步:选得分最高的作为第一个
best_idx = np.argmax([c['score'] for c in candidates])
selected_indices.append(best_idx)
candidate_indices.remove(best_idx)
# 循环选择剩余topk-1个
for _ in range(topk - 1):
mmr_scores = []
for idx in candidate_indices:
# 计算当前候选与已选所有新闻的最大相似度
max_sim = max([sim_matrix[idx][sel] for sel in selected_indices])
# MMR公式: λ * 得分 - (1-λ) * 最大相似度
mmr = lambda_param * candidates[idx]['score'] - (1 - lambda_param) * max_sim
mmr_scores.append(mmr)
# 选MMR值最大的候选
best_mmr_idx = candidate_indices[np.argmax(mmr_scores)]
selected_indices.append(best_mmr_idx)
candidate_indices.remove(best_mmr_idx)
return [candidates[i] for i in selected_indices]
# 使用示例
if __name__ == '__main__':
# 模拟一些新闻数据,每个新闻有一个随机生成的128维embedding
import random
news_list = []
for i in range(20):
news_list.append({
'id': i,
'title': f'新闻{i}',
'score': random.random(), # 精排得分
'embedding': np.random.rand(128) # 用随机向量模拟文本embedding
})
# 执行MMR重排,取前5个
reranked = mmr_rerank(news_list, topk=5, lambda_param=0.6)
for news in reranked:
print(f"id:{news['id']}, score:{news['score']:.3f}")
这段代码里,我们使用了预先计算好的新闻embedding(可以用NLP模型生成)。lambda_param是关键参数:值越大越看重相关性,越小越看重多样性。实际应用中可以根据业务调参,比如在新闻推荐中0.5到0.7通常效果比较好。
3.2 基于个性化点击率的加权重排
精排的得分通常是模型预测的点击概率,但不同用户对新闻的偏好还存在差异。我们可以利用用户历史点击的新闻类别、来源等信息,对候选新闻进行微调。比如用户最近常看科技类,那么科技类新闻适当提权,财经类适当降权。
# 技术栈: Python 3.8+
def personalized_weight_rerank(candidates, user_profile, boost_factor=0.2):
"""
基于用户画像的加权重排
:param candidates: list of dict, 每个元素包含 'id', 'score', 'category', 'source'
:param user_profile: dict, 用户画像, 例如 {'prefer_category': {'科技':1.5, '体育':0.8}, 'prefer_source': {'人民日报':1.2}}
:param boost_factor: 加权幅度, 越大权重影响越大
:return: list of dict, 重排后的列表(按调整后得分降序)
"""
for news in candidates:
# 基础得分来自精排
base_score = news['score']
# 根据类别加权
category_weight = user_profile.get('prefer_category', {}).get(news.get('category'), 1.0)
# 根据来源加权
source_weight = user_profile.get('prefer_source', {}).get(news.get('source'), 1.0)
# 综合调整: 新得分 = 基础得分 * (1 + boost_factor * (加权乘积 - 1))
# 这样可以保证权重在1附近浮动,不至于过度扭曲
new_score = base_score * (1 + boost_factor * (category_weight * source_weight - 1))
news['adjusted_score'] = new_score
# 按调整后得分降序排序
candidates.sort(key=lambda x: x['adjusted_score'], reverse=True)
return candidates
# 模拟数据
if __name__ == '__main__':
mock_news = [
{'id': 1, 'score': 0.9, 'category': '科技', 'source': '36氪'},
{'id': 2, 'score': 0.85, 'category': '体育', 'source': '腾讯体育'},
{'id': 3, 'score': 0.8, 'category': '科技', 'source': '澎湃新闻'},
]
# 用户画像:偏好科技,不偏好体育;喜欢36氪
user_pref = {
'prefer_category': {'科技': 1.3, '体育': 0.7},
'prefer_source': {'36氪': 1.2}
}
reranked = personalized_weight_rerank(mock_news, user_pref, boost_factor=0.3)
for n in reranked:
print(f"id:{n['id']}, 原分:{n['score']}, 调整分:{n['adjusted_score']:.3f}")
这个方法简单实用,用户画像可以从在线存储(如Redis)中实时获取。注意boost_factor不宜太大,否则会出现某条新闻因为来源偏好而碾压其他所有候选的情况。
3.3 基于时效性的新鲜度加权
新闻的时效性非常关键,一条12小时前的新闻和一条1小时前的新闻,即使点击率一样,用户也更倾向于看最新的。我们可以用时间衰减函数来调整得分。
# 技术栈: Python 3.8+
import datetime
import math
def time_decay_rerank(candidates, decay_hours=4, min_boost=0.5):
"""
时效性加权重排,采用指数衰减
:param candidates: list of dict, 每个元素包含 'id', 'score', 'publish_time' (datetime对象)
:param decay_hours: 衰减半衰期(小时),即经过这个时间后权重减半
:param min_boost: 最低权重,防止非常旧的新闻被完全抹杀
:return: list of dict, 按新得分降序
"""
now = datetime.datetime.now()
for news in candidates:
time_diff = (now - news['publish_time']).total_seconds() / 3600 # 小时数
# 指数衰减: weight = 2^(-time_diff / decay_hours)
weight = math.pow(2, -time_diff / decay_hours)
# 确保不低于最小值
weight = max(weight, min_boost)
news['time_weighted_score'] = news['score'] * weight
candidates.sort(key=lambda x: x['time_weighted_score'], reverse=True)
return candidates
# 模拟
if __name__ == '__main__':
from datetime import timedelta
now = datetime.datetime.now()
mock = [
{'id': 1, 'score': 0.9, 'publish_time': now - timedelta(hours=1)}, # 1小时前
{'id': 2, 'score': 0.88, 'publish_time': now - timedelta(hours=6)}, # 6小时前
{'id': 3, 'score': 0.85, 'publish_time': now - timedelta(hours=24)}, # 1天前
]
reranked = time_decay_rerank(mock, decay_hours=4, min_boost=0.3)
for n in reranked:
print(f"id:{n['id']}, 原分:{n['score']}, 时间权重:{n['time_weighted_score']:.3f}")
半衰期decay_hours根据新闻类型灵活设置:突发事件半衰期可以设为1小时,一般新闻可以设6小时。min_boost防止权重降到0导致某些重要但稍旧的新闻彻底无缘展示。
3.4 结合业务规则的硬约束
除了算法调整,有时候还必须加入硬规则。比如置顶一条重要公告、在特定位置插入广告、或者要求同一个作者的新闻不能连续出现。这些规则可以在重排最后阶段强制执行。
# 技术栈: Python 3.8+
def apply_rules_rerank(candidates):
"""
应用业务规则,比如禁止同一作者连续出现、强制置顶某条新闻
:param candidates: list of dict, 已按某种得分排好
:return: list of dict, 调整后的列表
"""
# 规则1: 强制置顶id为-1的公告(假设公告在候选里)
pinned_ids = [-1]
pinned_items = [c for c in candidates if c['id'] in pinned_ids]
others = [c for c in candidates if c['id'] not in pinned_ids]
# 规则2: 如果同一作者连续出现超过2条,则间隔至少隔1条其他新闻
result = []
last_author = None
same_author_count = 0
for item in others:
author = item.get('author', 'unknown')
if author == last_author:
same_author_count += 1
if same_author_count >= 2:
# 需要插入一个其他作者的新闻,简单做法:从后面找一个不同作者的调过来
# 这里为了演示,直接跳过这个,使用一个占位方案
# 实际工程中可以维护一个双端队列
continue # 跳过,后续可以补回(示例简化)
else:
same_author_count = 1
last_author = author
result.append(item)
# 把置顶的插到最前面
result = pinned_items + result
return result
# 模拟
if __name__ == '__main__':
mock = [
{'id': -1, 'author': 'system', 'title': '重要公告'},
{'id': 10, 'author': '张三', 'title': 'A'},
{'id': 11, 'author': '张三', 'title': 'B'},
{'id': 12, 'author': '李四', 'title': 'C'},
]
final = apply_rules_rerank(mock)
for n in final:
print(f"id:{n['id']}, author:{n['author']}, title:{n['title']}")
注意,这里的规则示例为了简洁做了简化,实际工程中需要更复杂的队列管理。但核心思想就是:在最终列表上施加约束,保证用户体验的底线。
四、应用场景分析
上面几种方法可以组合使用,效果更佳。常见场景如下:
- 新闻信息流:MMR + 时效性加权是标配,尤其对短时爆发的事件,必须控制同质化。
- 个性化资讯App:加上用户画像加权,让每个用户看到的内容排序都不同,提升留存。
- 电商推荐(类似新闻):商品知识性内容也需要多样性和新鲜度,比如一个用户看了手机,不能连续推手机壳、贴膜等关联产品,适当引入MMR。
- 视频/短视频推荐:同样适用,但需要将embedding换成视频特征。
五、技术优缺点
MMR方法:
- 优点:原理清晰,效果显著,能有效打破多样性瓶颈。
- 缺点:需要计算embedding相似度,数据量大时耗时长;
lambda_param需要手动调参。
个性化加权:
- 优点:对留存和点击率提升明显,计算简单。
- 缺点:用户画像的获取依赖历史数据,冷启动用户效果差;过分依赖画像可能导致“信息茧房”。
时效性加权:
- 优点:实现简单,显著提升新鲜感。
- 缺点:对非时效性内容(如深度报道、科普文)不友好,会过早沉没。
硬规则约束:
- 优点:保证业务底线,防止奇怪情况。
- 缺点:规则写死,可能限制优化空间,需要定期评估有效性。
六、注意事项
- 数据一致性:重排时使用的embedding、用户画像等需要保证与精排阶段的数据源一致,否则会出现矛盾。
- 实时性:重排是在线环节,对延迟敏感。MMR如果候选量很大(比如几百条),可以用近似最近邻加速相似度计算,或者只对top-N做二次重排。
- 冷启动:新用户没有历史行为,个性化加权可以降权或回退到仅使用MMR+时效性。
- A/B测试:任何重排策略上线前都要做A/B实验,观察点击率、阅读时长、访问深度等指标,避免只看单一指标。
- 可解释性:虽然重排是后处理,但最好能记录每个新闻的最终得分构成,方便排查问题。
七、文章总结
重排阶段虽然只是推荐系统整个流程里很小的一步,但却是直接影响用户眼前体验的关键环节。通过MMR控制多样性、个性化加权实现千人千面、时效性加权保持新鲜感、硬规则兜底保证质量,这几种方法配合使用,可以显著提升用户满意度。代码实现上也不复杂,用Python就能快速验证。希望大家在自己的推荐系统里试试这些方法,让用户刷新闻时少一些“审美疲劳”,多一些“惊喜”。
Comments