多目标优化与偏好策略推荐算法:从NSGA-II到自适应权重融合实践
1. 从“单点最优”到“一方综合最优”做算法这些年我越来越觉得“优化”这件事像极了过日子。年轻时总想着挣最多的钱于是所有精力都扑在单一目标上结果身体垮了、生活没了回头一算账反而是负数。后来想明白了真正好的人生是赚钱、健康、家庭、兴趣多方平衡下的综合最优解。推荐系统里的排序算法本质上也是这个道理。早年做推荐大家习惯了单目标排序——要么点击率要么转化率选一个指标猛砸。延长观看时长就优化完播提升变现就优化成交。但问题很快浮出水面只看点击推荐的全是标题党只看转化用户沉淀不下来平台生态越来越缺水。商业场景从不是单选题。短视频平台既要用户点进来又要看得久还得愿意付款下单电商系统既要当天下单转化又得照顾用户长期复购还得给新商家一点冷启动机会。几个目标之间还经常互相打架——点击高的内容通常转化差转化好的商品往往受众窄。于是“多目标优化”成了一个绕不开的工程命题。而更进一步用户本身又各有偏好。有人是重度薅羊毛党你推高客单价商品给他他根本不会点有人追求品质你老给他推九块九包邮他掏一次钱就卸载了。所以单点化的多目标还不够最好还能针对人群偏好做自适应的策略推荐就像一个好的硕士生导师对不同学生得用不同的培养方案。这篇文章我想把自己在这条路上的一些实际经验一个完整的可落地方案——多目标优化与偏好策略推荐算法从建模思路到工程调参再到源码细节完完整整拆给你看。在做这个模拟项目时我设计了一个具有三个业务目标的推荐场景指标间存在的天然冲突机制模拟了真实业务中的对抗与平衡然后用多目标进化算法去找解集再通过一个偏好网络模型把用户分群和兴趣偏好映射到目标权重空间实现在线自适应推荐。代码我放在了文末关于环境依赖、执行顺序和参数调整的说明我会边拆解边讲最终目标是让你跑起来、改得动、换了业务数据也能用。这套方案适合谁“多目标优化”方向的在校研究者、推荐系统从业者以及想从单目标模型往多目标方向落地尝试的工程师都能在里面找到一点可以参考的东西。文章里我会把“多目标NSGA-II”到“偏好权重生成”再到“线上打分融合”的完整链路拆开来讲纯个人项目实操视角夹杂大量踩坑记录和调参心得尽可能把“纸面方案”变成“跑得动的代码”。2. 多目标优化的核心思路与NSGA-II选型复盘先说一个常见的误区很多人一提到多目标优化第一反应是“把多个loss加权求和变成一个loss”然后用普通的梯度下降去优化。这个思路在推荐场景里往往会踩坑原因不在于加权不可行而在于权重怎么定。业务上点击率、时长、转化率之间的相对重要性是随用户和场景动态变化的用一个固定的系数去压平三个目标等于把所有用户当成了一个同质的群体最终出来的“最优”方案往往两头不讨好。之所以在这个模拟项目里采用NSGA-II带精英策略的非支配排序遗传算法核心原因也是看中了它“不预设权重”的特质。它把多个目标直接当作多维空间中的向量来处理通过Pareto支配关系去找一组“非支配解集”也就是传说中的Pareto前沿。在这个前沿上的任何一个解都不可能在所有目标上同时优于另一个解——此消彼长之间给出的是候选策略的“全谱系”而不是一个被权重锁死的单一答案。这与推荐的业务诉求天然吻合排序策略需要能应对不同偏好人群中“轻留存、重转化”或“重时长、轻点击”的不同取舍方式。NSGA-II里有几个关键机制值得展开说一下快速非支配排序把种群里的个体按支配关系分层第一层是当前最优的非支配解集第二层是被第一层支配的解集以此类推。每一层内部的个体再根据拥挤度距离排序保证解的分布性。拥挤度距离计算某个解与同一层内相邻解在目标空间中的距离距离越大说明周围越稀疏、代表性越强。这样在选择时能保留处于稀疏区域的解避免所有解扎堆在一个局部区域。精英保留策略父代和子代合并后统一筛选保证每一代的最优个体不会因为随机性而在进化过程中丢失。这样的机制换来的是“一次运行得到一组解”代价也很明显多目标进化算法的计算开销要比单目标梯度优化大得多种群规模、迭代代数、交叉变异参数都要仔细调。在我这个模拟项目里三个目标刻画了三个业务维度目标1可以理解成点击率期望目标2是时长/深度指标目标3是商业化转化指标。三者之间存在刻意构造的负相关关系生成方式上我特意让部分决策变量对这个目标有利就对另一个不利模拟业务里“点击诱饵”与“高质量长文”之间的天然冲突感。这样构造出来的测试问题虽然不能等同于真实线上数据但用来验证算法的机制是否有效是足够的——如果连可控的冲突场景都处理不好换了真实噪声更大的场景只会更糟。2.1 为什么选了NSGA-II而不是其他多目标算法这里稍微展开一下我的选型考量。多目标优化领域的算法很多常见的还有加权求和法实现简单但权重对结果影响太大且无法处理非凸的Pareto前沿容易漏解。不是算法本身不好是它不适合我们想要“一簇候选解”的诉求。MOEA/D基于分解的多目标进化算法把多目标问题分解成若干单目标子问题性能不错但对权重的初始分布敏感工程落地时需要先验知识。NSGA-IIINSGA-II的改进版引入了参考点机制在面对三个以上目标时分布性更好。我们这里三个目标NSGA-II够用而且NSGA-II的社区资料和开源实现更多排障方便。基于梯度的多目标方法如MGDA适合连续优化问题而推荐策略参数很多是离散、非连续变化的不太匹配。最终选了NSGA-II还有一个现实理由代码实现成本可控逻辑清晰便于在文章里把每个环节都讲明白。后面如果你想把目标扩展到四个以上再换NSGA-III或者加入参考点机制也不迟核心框架可以直接复用。2.2 模拟业务场景的设计策略我做模拟数据有一个原则再简单的模拟也要保留真实场景的核心矛盾。如果只是随机生成一堆无关联的数值算法跑完了也不知道证明了个啥。这次设计了三组决策变量每组的取值范围做了差异化处理目标函数的连线也是先想好“要让它们之间有冲突”再动手写的。其中一个目标用了类似峰形函数的结构决策变量在某个区间内越高越好另一个目标则相反在同样的区间是越低越好。这样同一组变量同时影响两个目标方向还相反就形成了我说的“对抗张力”。这也是多目标测试问题里最常用的构造手法方便观察算法最后是否真的收敛到了Pareto前沿的合理形态。然后我给问题加了一点小噪声用来模拟线上数据的不稳定性。这样做也带来一个额外好处评测算法时可重复性更高不会出现“跑一次一个样”的抓狂局面。随机种子在进化算法里非常重要我后面在代码注释里会专门标出来建议你跑实验时固定好种子再做对比不然不同次运行间的差异会掩盖算法改进带来的真实提升。3. 偏好策略推荐如何把“用户想要什么”变成“目标怎么权衡”如果你只做到上面一步——跑出一组Pareto前沿解——其实离业务落地还差着关键一环前沿上有几十个解线上该给哪个用户用哪个这时就需要“偏好策略”介入了。偏好策略要做的事用一个朴素的说法来解释就是把用户最想要的东西翻译成目标之间的权重关系。传统做法是让运营或产品经理拍脑袋定权重点击权重大一点时长权重稍低转化先不管。这种做法早期可以跑但越到后期越会露出疲态——用户是分层的不同层级的偏好差异很大。年轻人爱看短平快、中老年人更关注内容深度、价格敏感型用户喜欢折扣信息、高净值用户偏好品质品牌同一个固定权重怎么调都照顾不全。所以我在这套方案里加了一个“偏好网络”输入用户特征和上下文特征输出一组多目标权重向量和为1约束是每个目标权重大于0小于1。这样相当于学了从用户特性到目标权衡之间的映射关系在线预估时多了一个步骤——先出权重再用权重融合多个目标得分得到最终排序分。它的优点很实在用户分群不再是拍脑袋的事而是通过数据自己学到“什么样的人倾向于什么样权重组合”。偏好网络输出的权重是连续的能捕捉同一个用户在不同上下文下的偏好变化比硬分群更平滑。多目标模块和偏好网络解耦比如业务改了目标数量进化算法模块只要适配新增目标维度偏好网络只需改一下输出层维度就行。3.1 用户特征表示与偏好学习样本构造偏好网络需要监督信号这一步是我踩坑最密集的地方。你要知道我们通常没有“用户真实偏好权重”这种标注数据那你拿什么训网络我的做法是用“反推”的方式构造训练样本先用NSGA-II跑出一组Pareto前沿解每个解是一组参数配置然后把每个用户的历史行为样本拿过来用他真实的行为结果——比如哪些推荐被点击了、哪些产生了时长、哪些完成了转化——去评估这些解对他的“适用度”。如果某个解在他身上的加权综合表现最好那一组解的坐标和前沿上这个解对应的目标权重向量就可以当作这个用户的偏好标签。这里面有个细节要点一下我不是直接用目标原始值来算适用度而是先做了min-max归一化让不同量纲的目标值先落到同一个尺度上再做加权求和。否则转化率这种分子分母都在0到1之间的指标和时长达几十上百的指标完全不在一个量级权重会被量纲带偏。样本构造完成后我用一个两层的全连接网络输入用户年龄、性别、活跃度、历史点击率、历史平均时长等特征输出三维的权重向量。损失函数用均方误差让网络输出的权重向量逼近样本标签。这么训练出来的偏好网络在测试集上的表现是可接受的但训练过程中的坑也确实不少我在后面专门用了一节来记录。3.2 在线推理的完整链路整套系统在线使用时的流程大概是这样的从特征服务拿到当前用户和上下文特征。输入偏好网络得到目标权重向量。对候选物品分别计算出多个目标的预估值这里模拟环境里直接取真值或加噪值真实业务中会替换成对应目标模型。用目标权重对多目标的归一化得分做加权求和。按加权得分排序产出最终的TopN推荐列表。这个链路里最需要关注的工程问题是“权重输出的稳定性”如果偏好网络因为输入特征小扰动输出的权重在相邻两次请求间剧烈跳动那推荐结果会变得很不稳定用户会明显感知“推荐的玩意儿换了风格”。有一个我亲测有效的缓解方法——推理时对权重输出做指数滑动平均平滑系数建议在0.80.95之间太小没效果太大则会造成权重滞后于用户偏好变化。这个度要拿捏好。4. 实操过程与核心环节实现这节直接从代码层面讲落地过程环境是Python 3.9 NumPy核心依赖很少方便你快速跑通。下载项目后目录结构如下multi-objective-recommend/ ├── moo.py # 多目标优化核心NSGA-II实现 ├── preference.py # 偏好网络训练与在线推理 ├── data.py # 模拟数据和样本构造 ├── run.py # 一键执行完整流程 └── README.md # 项目说明与参数指南建议的执行顺序先完整跑一遍run.py确认能出结果再逐模块细读。我的所有模块都预留了默认参数直接运行也能通但只有理解了每个参数的物理含义你才能把它迁移到自己的业务场景里。4.1 NSGA-II核心代码的关键要点NSGA-II的实现我尽量保持了经典论文里的范式但有几处工程化的设计细节讲一下初始化。种群个体不是在决策空间里均匀随机撒点而是结合每个决策变量的取值范围做了边界约束。比如变量区间是[-5, 5]初始化时就用np.random.uniform(-5, 5, size)生成。千万别忽略边界处理很多新手写的遗传算法性能差根源就在这里——后代交叉变异之后直接跑出边界变成了非法解你还得再花代码去处理约束违反度。交叉与变异。我用的模拟二进制交叉SBX和多项式变异这两个是实数编码遗传算法的标配。参数上交叉概率我设了0.9变异概率设了0.1分布指数都用了20。这里有一个调参要点交叉概率太低会导致种群收敛变慢变异概率太高则会变成随机搜索收敛性会很差。如果是做真实业务数据的调参建议先固定一个再对另一个做网格搜索。非支配排序。每个个体需要记录两个关键属性domination_count支配这个个体的个体数和dominated_solutions这个个体支配的个体列表。这是个O(N^2)的计算N是种群规模。我在100的种群规模下跑200代是很快的但如果你把种群规模提到500甚至1000这个排序会成为性能瓶颈可以考虑用基于分解的排序来替代这是后话。下面是核心排序逻辑的简化版框架def fast_non_dominated_sort(values): # values: 2D array, shape(pop_size, n_obj) pop_size values.shape[0] domination_count np.zeros(pop_size, dtypeint) dominated_solutions [[] for _ in range(pop_size)] fronts [[]] for p in range(pop_size): for q in range(pop_size): if p q: continue if dominates(values[p], values[q]): dominated_solutions[p].append(q) elif dominates(values[q], values[p]): domination_count[p] 1 if domination_count[p] 0: fronts[0].append(p) # 逐层剥离被支配个体生成每一层front i 0 while fronts[i]: next_front [] for p in fronts[i]: for q in dominated_solutions[p]: domination_count[q] - 1 if domination_count[q] 0: next_front.append(q) i 1 fronts.append(next_front) fronts.pop() # 删除末尾的空front return fronts这段代码的核心手法在于从第一层开始每次剥离当前层支配过的所有个体把支配计数减一减到零的个体自动进入下一层。用这种方法每一层的成员在迭代过程中就自动确定了不需要额外判断。实际运行中我当时遇到一个性能坑fronts.pop()这行如果漏了最后的空列表会把后续排序逻辑弄乱导致明明已经很接近Pareto前沿的解被判成更差的前沿层级。这类边界case你光读算法论文是看不出来的必须实跑之后evaluate一下前沿形状才能发现问题。4.2 偏好网络的训练实现细节训练样本构造完成后我封装了一个非常轻量的三明治结构网络class PreferenceNet: def __init__(self, input_dim, hidden_dim, output_dim): self.w1 np.random.randn(input_dim, hidden_dim) * 0.1 self.b1 np.zeros(hidden_dim) self.w2 np.random.randn(hidden_dim, output_dim) * 0.1 self.b2 np.zeros(output_dim) def forward(self, x): hidden np.tanh(np.dot(x, self.w1) self.b1) output np.exp(np.dot(hidden, self.w2) self.b2) return output / np.sum(output, axis1, keepdimsTrue)输出层用了Softmax归一化保证权重向量是一个和为1的概率分布。前几轮我试过不加Softmax、直接在训练后手动归一化效果差很多——没有Softmax约束网络不会自动学习“输出分量间权衡”的结构容易退化成全输出同一类权重的平凡解。训练参数上我用的是带动量的SGD学习率设了0.01动量0.9。这里有个经验均方误差在训练初期下降非常快但到了60轮左右就会进入平台期此时最好的做法是降低学习率到原来的十分之一而不是加大训练轮数。我在代码里加了set_learning_rate()接口方便你在训练中途切换学习率。4.3 一键执行完整流程run.py里把整条链路串了起来总共分几个阶段生成模拟数据构造冲突型三目标问题并生成一批模拟用户及其行为。多目标优化运行NSGA-II得到Pareto前沿结果。样本构造根据每个用户的历史行为挑选最适用的前沿解形成训练数据。偏好网络训练训练一个MLP将用户特征映射为权重向量。离线评估在测试用户集合上对比固定权重和偏好权重下的综合收益指标。直接運行python run.py即可看到输出包含Pareto前沿点数、各个目标的分布范围、偏好网络在测试集上的误差、以及固定权重和自适应偏好权重的综合收益对比。这里提醒一点如果你把种群规模和迭代代数改得过大运行时间会成倍上涨。我的默认参数是在普通家用CPU上验证过的基本一分钟以内能跑完适合快速验证。要正式跑实验对比时再加大参数。5. 常见问题与排障实录这一节我把实际调这个项目时最常遇到的几个问题拎出来。网络上都很少见到有人把这些细节写在文档里但对跑通代码和做实验对比来说它们恰恰是最致命的。5.1 为什么NSGA-II跑出来的Pareto前沿只有孤立的几个点这个现象非常典型。一开始我以为是代码实现有问题后来排查发现根源在目标函数值域的差异太大。三个目标里一个的取值范围在0到1之间另一个在几十到几百的区间还有一个在0到5之间。拥挤度距离计算时值域大的目标天然主导了距离贡献导致算法把搜索资源都集中在优化那个大值域的目标上其他目标几乎原地不动。最终呈现出来的前沿就是“只有少数几个点而且分布极不均匀”。解决办法有两个方向先归一化目标值再做排序对所有目标值做min-max归一化让它们落在同一量纲再做支配关系和拥挤度计算。这个方法最简单我后来就在代码里采用了。改进拥挤度距离的度量方式对不同目标的距离贡献做标准化再求和本质上和第一种方案类似但侵入度更深需要改排序核心。建议优先做第一种改动面小效果立竿见影。做完之后前沿点的分布性会肉眼可见地变好。5.2 偏好网络输出的权重总是趋同无法体现用户差异这个问题的样本构造侧症结通常是训练样本里不同用户的标签其实非常接近你怎么训网络都没法学出差异。我在排查时打印了样本标签的分布发现绝大部分用户的“最优解”都是同一个前沿解——原因是我做样本构造时使用的行为数据量太少噪声太大导致综合收益的差距被噪声抹平了。也就是说不管你偏好如何算出来收益最高的都是同一个解那偏好网络当然学不到用户间的差异。对策是提高样本的区分度增加每个用户的模拟行为数量降低单条行为的随机性。让不同用户群体的真实偏好差异更大。比如模拟时给“价格敏感型”用户的转化率权重倾向更高“时长偏好型”用户的时间深度权重更高这样前沿解之间的胜负关系就会更清晰。在构造标签时不只用“最高收益解”而是用收益Top-K个解做加权平均让标签保留一定柔软度训练收敛更快。经过这三项调整之后偏好网络输出的权重向量会明显区分出不同用户群测试集上的效果也有了实质提升。5.3 遗传算法的随机性导致每次结果差异巨大这是进化算法新手最常见的困惑。同一份代码两次运行结果可能差很多。这在做实验对比时尤其要命——你没法判断性能提升是算法改进带来的还是随机种子带来的。我的应对策略是在所有涉及随机数的地方固定种子np.random.seed(42)这种。注意是每个模块运行前都要固定不只是在主入口固定一次。做正式实验时至少跑5个不同的种子汇报均值和标准差。只看一次运行的结果下结论在进化算法相关的实验里是大忌。5.4 实测下来这个方案最值得关注的两个工程细节第一个是模块解耦。我把多目标优化、偏好网络训练、在线打分做成了三个独立模块。后来好多次改进都是因为解耦才能快速迭代——比如换了一个目标函数只需要改data.py里目标函数部分换一种偏好网络结构只需要改preference.py。如果当初把它们写成一坨估计每次改动都得重写一遍。第二个是评估指标不能只看单点收益。我在离线评估时除了对比综合收益外还单独看了三个目标的各自变化。如果你做的推荐系统业务场景是从多个单目标模型往多目标迁移建议上线前一定要准备分目标的效果监控以免优化了综合指标某个核心业务目标却出现明显下滑——这种问题在指标加总时容易被掩盖但在分目标监控下无处遁形。6. 从模拟到真实业务的扩展建议如果你看完前面这些内容已经顺利在自己的机器上跑通了这套代码下一步一定会面临一个问题这套模拟环境里的流程换到真实业务里要动哪些地方结合我自己的实践经验给你几条参考路径决策变量如何对应到真实策略。在模拟问题里决策变量只是一个数字方便算法搜索。但在真实推荐场景里每个决策变量需要映射到具体的可执行策略。比如一个决策变量可以代表“召回候选集的多样性阈值”另一个代表“点击得分与时间得分的组合系数”还有一个代表“对低转化率用户的重排折扣”。这样做的好处是NSGA-II搜索出的每一个解都能直接翻译成线上可做对照实验的一组策略参数。目标模型从真值替换成预估模型。模拟环境里我偷了个懒直接在计算目标值时读取的是“真值加噪声”这在真实业务里不存在。你需要替换成已有的或临时训练的CTR预估模型、时长预估模型、CVR预估模型等每个返回一个分数再做归一化融合。这里有个实际经验不同模型的分数分布差异很大归一化时不建议用固定的历史min-max而是用近一段时间的分位数来标准化抗扰动能力更好。增量学习与偏好漂移。用户的偏好不会一成不变。今天喜欢看宠物视频的用户下个月可能迷上了自驾旅行。偏好网络如果只是离线训练固定权重线上效果会随时间衰减。建议做成周期性重训练或者用在线学习的方式对网络做微调。在实践中我发现每天用前一天的新行为数据做一次小步长微调性价比很高模型能跟上偏好的短期漂移。多目标结果集的存储与检索。NSGA-II跑出来的Pareto前沿解可能有上百个在线推理时每个用户都要把所有候选解都评分一遍吗不需要。更优的做法是对偏好网络输出的权重向量在前沿解里做最近邻检索比如通过向量相似度选出Top-K个最匹配的解再对这K个解做加权融合。这样既保留了“多解可用”的弹性又把在线计算压到了一个可以接受的范围。这些扩展点如果你有兴趣我后续可以再开专题讲。篇幅有限这节先点到为止。最后分享一点个人体会这个项目做下来我最大的感受是多目标优化真正的难点不在于算法本身而在于你能不能从业务里提炼出清晰的目标冲突结构并且愿意接受“没有完美解只有权衡解”这个前提。NSGA-II给我们的不是“哪个解最好”的答案而是一组“在哪些权衡下能达成什么结果”的候选解。偏好网络再进一步把这组候选解分发到适合它们的人身上。运行这套代码时建议你先用默认参数跑一遍再试着把其中一个目标的冲突强度调大观察Pareto前沿的形状变化。我调试时最爱干的一件事就是把前沿点画出来然后盯着图看一会儿思考为什么某些区域的解特别密集哪些区域的解又是稀疏的——这些视觉信号比任何指标都更直观地告诉你算法在做什么。最后再给一个小建议代码里所有可调参数我都在注释里标了“物理含义”你拿到之后可以对照调参。如果发现偏好网络怎么调都学不好优先检查训练样本的区分度而不是网络结构或学习率这几乎是我踩过的坑里最容易反复折腾的方向。