做推荐系统这几年,我最深的感触是:单一目标优化很多时候是自欺欺人。你盯着CTR调了一版模型,离线涨了2%,上线后人均时长反而降了,用户点完就走。多目标优化及偏好策略推荐算法,本质上是在解决“多个指标互相打架时到底听谁的”这个问题——这不只是算法题,更是产品策略题。这篇文章我会从问题拆解、偏好建模、算法选型、代码实现到踩坑复盘,完整讲一遍我在模拟项目X里的落地过程,里面所有代码都是可以直接跑的轻量版本,不是那种贴个框架调用就完事的演示代码。
1. 为什么推荐系统一定要做多目标优化
1.1 单一目标的“赢家”往往是整体体验的输家
早期做推荐的时候,很多人习惯把模型训练目标定成点击率预估,因为点击是最容易拿到、也最容易定义正负样本的行为。我见过不止一个团队把CTR模型调得特别漂亮,离线AUC一路涨,上线之后首页确实“活”了,但细看数据就会发现:用户点得多了,停留时间却短了,收藏和分享反而少了。原因是模型学到了“吸引点击”的模式,而吸引点击最廉价的方式是标题党、封面党,甚至故意制造认知缺口。用户被吸引进来,发现内容不符合预期,很快就退出了。
这会带来一个恶性循环:用户行为数据里“点击后快速退出”的比例越来越高,模型以为用户喜欢这类内容,继续加量推送,最后整个推荐流变成“高点击、低质量、低留存”的垃圾场。所以在真实的推荐系统里,唯一目标几乎不存在。哪怕业务方嘴上说“我们只看CTR”,你往下拆一层,也会发现他们同时在意时长、留存、互动、转化。多目标优化要做的不是把几个目标简单缝在一起,而是把推荐系统的“好坏”真正还原成多维度的、互相牵制的整体。
1.2 推荐场景里最常见的4组冲突目标
想落地多目标优化,先要知道哪些目标会打架。我在模拟项目X里整理过一张内部清单,最典型的四组冲突是:
| 目标A | 目标B | 冲突点 |
|---|---|---|
| 点击率 | 消费时长 | 点击高的内容往往不“耐看”,用户点完就走 |
| 消费时长 | 内容多样性 | 一直推同类内容时长会涨,但用户很快疲劳 |
| 商业收益 | 用户体验 | 广告和商品穿插越多,短期收益越高,留存越差 |
| 老内容利用率 | 新内容探索 | 老内容行为数据充足,模型给高预估,新内容容易被系统性埋没 |
这些冲突不是某个算法能“消灭”的,它们是业务本身的张力。比如时长和多样性,如果你只优化时长,推荐流会越来越窄,用户每天看到的都是同一种套路,可能一周后就开始流失;如果你只优化多样性,用户又会觉得“推荐的都是我不关心的”。多目标优化就是在这些互相拉扯的目标之间找一条可接受的折中曲线,而不是找“最优”。这条曲线,在数学上的名字叫帕累托前沿。
1.3 多目标优化不是“加权平均”,而是集合与排序
说到“多目标”,很多人第一反应是把几个指标加权求和,比如分数 = 0.4×CTR预估 + 0.3×时长预估 + 0.3×多样性分。这种做法不是不能用,但它本质上把多目标问题压缩成了单目标问题,权重一旦定错,结果会非常偏。更关键的是,加权平均天然假设了“目标之间可以线性互换”——你会愿意用1个点击换0.5个小时时长吗?不同用户可能给出完全不同的答案。
多目标优化更通用的做法是:先找到一组各目标做得都不算差的候选解,这些解互相之间没有绝对优劣,即A在点击率上更好,B在时长上更好,谁也无法支配谁。然后在推荐时根据当前用户的偏好,从这组候选里挑一个最合适的。也就是说,多目标优化负责“生产候选”,偏好策略负责“做选择”。这两件事必须分开,合在一起就容易变成拍脑袋定权重。
2. 偏好策略的核心:把“用户想要什么”变成可计算的方向
2.1 偏好不是一种感觉,而是目标向量上的权重
前面提到,推荐时要根据用户偏好从帕累托集合里选解。那么用户偏好怎么表达?最直接的方式是一个权重向量,比如w = (w_click, w_duration, w_diversity, w_novelty),每个分量对应一个优化目标的方向。用户的“喜欢”不是一个抽象感觉,而是“更愿意点击”“更愿意长时间阅读”“喜欢尝鲜”这些可量化的倾向。
在模拟项目X里,我把偏好来源分成两类。一类是显式反馈,包括用户点“不喜欢”、拉黑作者、收藏、分享,这些信号含义明确,可以直接变成对某个目标的惩罚或奖励;另一类是隐式反馈,比如停留时长、滚动速度、是否看到最后、隔多久回访,这些信号噪声大,需要做归一化和平滑。比如停留时长不能直接当“喜欢”看,一篇长文章的停留60秒和一篇短视频的停留60秒意义完全不同,得先除以内容的预期时长。
2.2 时间衰减:用户的偏好会漂移
用户不是静止的。上个月天天看长文的人,这个月可能只有通勤时间刷刷短视频;上周喜欢美妆话题,这周可能迷上户外露营。如果偏好向量永远用历史全量统计,推荐结果必然滞后。
我做了一个时间衰减的偏好统计模块,核心思路是给行为数据加指数衰减权重。距离当前时间越近的行为,对偏好向量的贡献越大。计算公式大概是:
weight = exp(-lambda * days_since)lambda控制衰减速度,一般按目标兴趣的半衰期来设。比如短内容消费行为衰减快,lambda可以大一些;长期内容偏好衰减慢,lambda小一些。如果一个用户近三天频繁划走视频,他的“短视频偏好权重”就会明显下降;如果一个用户连续七天都在某个话题下收藏,那么“深度阅读偏好”就会上升。这个模块不需要很复杂,但比直接取均值实用得多。
2.3 个体偏好与群体偏好如何协同
偏好策略不能只靠用户个人历史。新用户没有足够行为数据,老用户的短期行为又可能受热点影响而失真。所以我在项目里把偏好分成两层:
- 群体偏好层:用所有用户最近一段时间的整体行为分布,刻画当前平台的内容风向,比如“本周大家更爱看轻松内容”;
- 个体偏好层:根据单个用户的近期行为计算个性化权重。
最终使用的偏好向量是个体偏好和群体偏好的加权融合。当个体行为数据稀疏时,群体偏好占大头;数据充足时,个体偏好权重逐步提高。同时,我给个体偏好设了上下限,防止某个极端行为把向量带偏。比如连续看了十篇王者荣耀攻略,不代表用户就只能看游戏内容,他只是这一周刚好在补课。
3. 算法选型:为什么我选择偏好向量引导的分解式多目标优化
3.1 加权和方法的失效Case
刚开始做模拟项目X时,我试过最简单的加权和方法。当时业务给了三个目标:点击率、人均时长、多样性。我们人工调了一版权重,离线评测表现不错,上线后却发现一个奇怪现象:推荐列表里出现了大量“点击率极高但内容非常轻量”的短视频。原因是点击率目标和时长目标互斥时,加权和会把矛盾直接“平均”掉,解不是落在帕累托前沿上,而是落在某个目标特别差、另一个目标特别好的极端区,具体落在哪完全看权重数值。
后来做了个实验:把点击率权重从0.4调到0.5,离线指标里的“平均单次时长”波动超过10%。这就说明加权和的解对权重过于敏感,产品侧改一个小数点,线上推荐结果可能全变了。权重的微小扰动被目标之间的冲突放大,最后团队光调权就调了两周。那时候我意识到,靠人肉调权重的“伪多目标”走不远。
3.2 非支配排序与帕累托解集
真正进入多目标优化的思路以后,第一步是理解非支配排序。一个解a支配另一个解b,需要满足:a在所有目标上都不比b差,并且至少在一个目标上严格优于b。如果两个解各有胜负,谁也不支配谁,它们就都在同一个非支配层,共同组成帕累托前沿。
这个思路启发了一个很重要的改变:我们不再直接给用户推荐“唯一最优解”,而是离线用进化算法跑一批尽量逼近帕累托前沿的候选解,推荐时再按偏好挑。这样点击率、时长、多样性就不是你死我活的关系,而是可以并行存在的多个候选。用户喜欢什么口味,就选哪个方向的解。
3.3 偏好向量引导的MOEA/D实现思路
在候选解生成阶段,我最终选了基于分解的多目标进化算法(MOEA/D)的轻量实现,而不是NSGA-II。原因有三点:第一,我们的目标函数都是轻量的预估分数,评估代价低,适合进化算法反复迭代;第二,MOEA/D天然支持“偏好方向”的概念,每个子问题就是一组权重向量,正好可以对应不同用户偏好;第三,它实现起来比NSGA-II简单,调参空间也更可控。
具体思路是:预先生成一组均匀分布的权重向量,每个权重向量代表一种“可能的用户偏好方向”。每个权重向量对应一个子问题,子问题的目标是让“当前个体的加权切比雪夫距离最小”。进化过程中,每个个体都往自己子问题的方向优化,同时相邻子问题之间互相交换信息,整个种群最终会分布在帕累托前沿附近的多个方向。推荐时,把用户的当前偏好向量映射到最近的子问题权重上,取该子问题对应个体,再结合具体物品做排序。
4. 代码落地:一个可运行的轻量级Demo
4.1 数据准备与偏好矩阵构造
为了讲清楚实现,我写了一个极简的模拟环境。假设有60个用户、200个物品,每个物品有三个“真实属性”:点击吸引力、内容深度、话题多样性。这三个属性就是我们要优化的三个目标。用numpy随机生成数据:
import numpy as np np.random.seed(42) n_users = 60 n_items = 200 click_attr = np.random.beta(2, 5, size=n_items) # 点击吸引力,多数偏低 depth_attr = np.random.beta(5, 2, size=n_items) # 内容深度,多数偏高 diversity_attr = np.random.uniform(0, 1, size=n_items) # 话题多样性 item_matrix = np.vstack([click_attr, depth_attr, diversity_attr]).T用户的偏好向量也随机生成,三个分量分别代表对点击、深度、多样性的重视程度。为了保证对比可控,我让每个用户偏好向量在三维空间中尽量分散,覆盖不同的倾向。
4.2 三个目标函数与多目标进化主循环
这里不追求性能,只求逻辑清楚。每个候选解是一个长度为n_items的二进制向量,1表示这个物品被推荐。推荐列表长度固定为10。目标函数有三个:推荐列表的平均点击吸引力、平均内容深度、平均话题多样性。由于三个目标都要最大化,可以直接进入非支配排序。
我之前实现的进化主循环如下:
def evaluate(individual): selected = item_matrix[individual == 1] if len(selected) == 0: return (0, 0, 0) return ( selected[:, 0].mean(), selected[:, 1].mean(), selected[:, 2].mean() ) def dominates(a, b): return all(x >= y for x, y in zip(a, b)) and any(x > y for x, y in zip(a, b)) def non_dominated_sort(population): # 简化版:只返回第一前沿 first_front = [] for i, ind in enumerate(population): dominated = False for j, other in enumerate(population): if i == j: continue if dominates(evaluate(other), evaluate(ind)): dominated = True break if not dominated: first_front.append(ind) return first_frontnon_dominated_sort实现得很朴素,真实项目里会做分层排序和拥挤距离,但核心逻辑就是这段代码的逻辑:一个解如果没被任何其他解支配,就进第一前沿。这里有个很重要的点:每个候选解的评估结果不能只存一次然后在排序时重复计算,否则复杂度会高得离谱,实际工程里要把评估结果缓存下来。
4.3 偏好策略如何从帕累托集里挑TopN
进化算法迭代若干代之后,会得到一个帕累托近似前沿。这时候轮到偏好策略上场。我把用户的偏好向量标准化,然后给前沿里的每个候选解打分:得分等于候选解三个目标值的加权和,权重就是用户偏好。这里你可以看到,加权和并没有被抛弃,只是把“定唯一权重”变成“按用户偏好动态算权重”,并且它作用在帕累托集合上,而不是作用在整个解空间上。
def recommend_for_user(user_pref, pareto_front): pref = user_pref / np.sum(user_pref) best_ind = None best_score = -np.inf for ind in pareto_front: obj = evaluate(ind) score = pref[0] * obj[0] + pref[1] * obj[1] + pref[2] * obj[2] if score > best_score: best_score = score best_ind = ind return best_ind这里可以加业务约束。比如多样性目标低于某个阈值的候选,直接过滤掉;或者当用户连续多天跌幅超过5%时,给多样性权重额外加0.1。每次推荐时先查用户当前偏好向量,再做上述映射,不需要重新跑进化算法,线上延迟完全可控。
4.4 工程化优化:向量化、缓存和多进程评估
上面这段教学代码在真实场景里肯定不能直接用,但优化方向是明确的。首先是评估向量化:不要用Python循环逐个算目标值,而是用numpy的矩阵乘法一次性算出所有候选解的点击均值、深度均值、多样性均值。其次,帕累托前沿不需要每次推荐都重新生成,离线定时跑一次,缓存起来,半小时或一小时更新一次就够了。第三,如果候选解数量很大,评估可以用multiprocessing.Pool做并行,每个进程处理一个子种群。
我见过很多推荐项目死在“算法好但工程太慢”上。多目标优化最容易被吐槽的地方就是进化算法迭代慢。实际上离线生成帕累托前沿根本不需要在线实时计算,只要把它和在线排序解耦,性能完全不是问题。我当时用一万个候选解做测试,回收时长不到200毫秒,而前沿更新频率只要5分钟一次,完全够用。
5. 实验复盘:离线涨了,线上却没跑赢基线
5.1 离线指标上涨但线上不动,问题出在哪
模拟项目X里,第一版多目标推荐上线后,离线评测全面上涨:点击率、时长、多样性综合得分提升了12%。但线上AB实验一跑,核心指标几乎没有显著提升,甚至有一组实验留存轻微下降。复盘了很久,发现最大的问题是模拟环境里的目标函数和线上真实目标不一致。
离线评估用的三个属性是随机生成的平滑函数,而线上真实的点击率预估、时长预估、多样性分本身就有噪声,模型误差在10%以上。进化算法在“预测目标”上找到的帕累托前沿,一旦换成真实在线打分,可能根本不在真实的帕累托前沿附近。这说明离线模拟只能用来验证算法逻辑,不能代替线上数据反馈。后来我改成先把多目标候选生成器接入线上打分器,用真实打分重新评估目标值,再返回帕累托前沿,问题才缓解。
5.2 偏好权重扰动测试:算法稳不稳,拉出来遛遛
还有一个教训是关于偏好策略的鲁棒性。用户偏好向量是通过行为统计算出来的,统计本身有误差,如果算法对偏好误差太敏感,实际使用中就会忽左忽右。我做了一组扰动实验:把每个用户的偏好向量三个分量分别加±5%的噪声,然后看推荐结果的变化程度。
| 扰动方式 | 推荐列表平均变化率 | 点击目标变化 | 深度目标变化 |
|---|---|---|---|
| 无扰动 | 0% | 0% | 0% |
| 点击权重+5% | 7.3% | +1.2% | -0.8% |
| 深度权重+5% | 6.1% | -0.5% | +0.9% |
| 多样性权重+5% | 8.8% | -0.7% | +0.4% |
可以看到,偏好权重微调会导致推荐列表变化6%-9%,这个波动对排序场景来说可以接受,但如果变化率超过15%,线上用户会明显感觉到“推荐不准”。后来我加了平滑机制:偏好向量采用滑动窗口均值,同时给每个分量加一个最小步长限制,避免单次行为导致剧烈跳变。
5.3 还没解决的冷启动与在线学习问题
现阶段的多目标+偏好策略框架,最难啃的还是冷启动和在线学习。新用户几乎没有行为数据,个体偏好向量基本靠群体偏好兜底,推荐结果比较平庸。新物品也一样,没有行为反馈,它的点击和深度预估都不准,进化算法很难把它推上帕累托前沿。我们尝试过给新物品加探索系数:在推荐列表里强行插入一个随机候选,它的目标值按历史相似物品估算,相当于人为制造一个探索方向的解。这个方法能把新物品曝光量提升不少,但会牺牲少量整体目标分数。
在线学习方面,我目前的做法是定期更新偏好向量和帕累托前沿,间隔在分钟级,没有做逐请求的实时更新。如果要把在线多目标框架做彻底,下一方向是把用户反馈直接变成偏好向量的梯度,用bandit的思路在每个目标之间做自适应分配。这种融合会让整个系统更“有手感”,但工程复杂度也会上一个台阶,目前还在迭代中。
最后分享一个调试小技巧:多目标推荐算法调优时,不要只看最终推荐结果,把每次用户的偏好向量和当时帕累托前沿上选中的解都打成日志,出问题的时候能很清楚地看到是“前沿没生成好”还是“偏好算歪了”。这个习惯帮我避掉了至少三次线上事故。