ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

DPP重排算法:用行列式点过程优雅兼顾推荐多样性与相关性

DPP重排算法:用行列式点过程优雅兼顾推荐多样性与相关性 做推荐系统重排序的时候我遇到过很多次类似的场景召回阶段拿回来的几百条内容单独看每条质量分都很高相关性也没问题但一排序前面几页全是同一类内容。用户要么划两下就走要么觉得系统“猜得太窄”体验一言难尽。我最早用MMR、用贪心去重、用各种启发式惩罚项去压同质化效果总是差一口气——不是压制太狠导致相关度崩了就是压了个寂寞多样性几乎没有。直到后来组里做视频推荐的同事把行列式点过程Determinantal Point Process, DPP甩到我面前我才第一次意识到原来“多样性”这件事可以有一个优雅的概率模型来定义而且能和“质量”比较自然地融合到一个目标里。这篇不打算写成教科书而是按我自己的理解把DPP从“它到底在解决什么问题”开始再讲到核心公式背后的几何直觉、实际落地时的核矩阵构造、采样和MAP求解的取舍最后整理一些我在工程中踩过的坑。如果你也在做推荐重排、主动学习、文档摘要、视频关键帧抽取这类“从一堆候选项里挑一个高相关性又多样化的子集”的需求这篇文章应该能帮你少走不少弯路。1. 先搞清楚DPP到底解决什么问题1.1 从“选一批”到“多样且高质量”的建模平常我们说的推荐、检索大多数时候是在做“选一个最优”——给定查询返回排序列表。但现实中很多需求本质上是“选一批”而且这一批里既不能都是同一个风格的也不能为了分散而把最好的几个全扔了。举几个我实际做过的场景电商推荐一个详情页要展示10个相似款但相似款里如果全是同色同材质同价位的用户很容易审美疲劳。理想情况是颜色拉开、风格拉开、价格带覆盖高中低。视频关键帧抽取一段10分钟视频要取8帧生成封面轮播如果8帧全是画面亮度、构图类似的静态画面封面吸引力很差如果全挑动作最激烈的几帧可能又过于集中在一个时间段。主动学习要从未标注样本里挑一批交给标注人员。只挑模型最不确信的容易全挑到同一类难例只挑有代表性的又可能漏掉真正需要关注的hard case。这些场景的共同点在于候选集合里每个元素本身有个体质量分相关性、置信度、收益同时元素之间还有相似度我们希望选出来的子集在“质量总和”与“内部多样性”之间取得平衡。传统做法里最出名的是MMRMaximal Marginal Relevance它每次贪心地选一个“本身分高、且与已选集合相似度低”的项加进去。MMR简单、可解释但问题也很明显它是贪心式的每一步只考虑当前增益不能保证全局组合最优而且“多样性”的调节系数lamda不太好调调大了结果很发散调小了几乎退化成单纯按分排序。DPP做的事情本质上是对“该选择某个子集”这件事直接建模一个概率分布。概率高意味着这个子集“质量高且多样”于是选子集就变成了在这个分布上采样或求最大概率子集。它把质量和多样性的权衡用数学上非常漂亮的方式统一了起来。1.2 行列式怎么就和“多样性”扯上关系了很多人第一次看到DPP的名字都会愣一下行列式Determinant不是线性代数里那个计算体积的玩意儿吗和挑选子集有什么关系关键就在几何直觉上。假设我们有N个候选样本每个样本被表示成一个向量可以是特征向量也可以是某个特征空间里的点。这N个向量两两之间的相似度可以用一个N×N的格拉姆矩阵来表示——矩阵里的值越大说明两个样本越相似。现在要对其中一个子集S计算一个指标这个指标需要满足两件事S里的样本个体质量越高指标越大S里的样本之间越相似指标越小。行列式恰好同时满足这两个性质子集对应的子矩阵的行列式可以理解为这组向量在特征空间中张成的体积。如果向量彼此方向接近高度相似体积就趋近于0如果向量彼此正交完全不相似体积达到最大如果向量长度越长个体质量越高体积也会相应变大。所以“在DPP里挑选一个多样性好的子集”在几何上就是在找一个体积大的子空间。行列式在这里不只是一个数学工具它本身就编码了我们对“好子集”的直观理解。这也是为什么DPP能在众多多样性模型里胜出的根本原因它不是外加一个惩罚项去硬压相似度而是从子集整体分布的高度直接建模。1.3 DPP的经典定义和那个核心公式正式一点说一个点过程Y是定义在候选集合U上的一个概率测度。对于任意子集A我们关心的通常是[ P(A \subseteq Y) \det(K_A) ]其中K是一个N×N的半正定核矩阵K_A是K在A对应下标上的子矩阵。这个公式的含义是任意一个候选子集A被“覆盖”的概率等于K_A这个子矩阵的行列式。但我们实际选择时更常用的是另一种参数化形式用L矩阵来表示[ P(Y S) \frac{\det(L_S)}{\det(L I)} ]这里L是非负半正定矩阵I是单位矩阵。L_S是L在子集S对应行列上的子矩阵。分母det(LI)是一个归一化常数保证所有子集的概率之和为1。这个公式是DPP所有工程实践的核心。构造L矩阵时最常见的方式是[ L_{ij} q_i \cdot \phi_i^T \phi_j \cdot q_j ]其中q_i是第i个样本的质量分可以是相关性得分、置信度、收益等phi_i是样本i的归一化特征向量phi_i^T phi_j就是样本i和j的相似度余弦相似度。这样一来对角线元素L_ii q_i^2体现了质量非对角线元素则同时受质量和相似度影响。行列式det(L_S)会把对角线上的“质量”相乘同时非对角线的“相似度”越大行列式越小。这也是为什么DPP能自然兼顾质量和多样性质量项直接影响子集的概率基底相似度项通过行列式的几何性质施加“排斥力”。2. DPP的两个核心问题采样与MAP2.1 先分清两个不同任务方向DPP真正在工程里落地通常面临两个方向的求解问题。第一个是采样Sampling按照P(YS)这个分布去随机抽取一个子集。这在需要“每次结果略有差异”的推荐场景里非常常见比如推荐流每次刷新想给用户不完全一样但质量都还不错的组合。第二个是求最大概率子集也叫MAP推断Maximum a Posteriori Inference在所有的子集里找到概率最大的那个。这对应的是“给定一次推荐请求如何选出固定的最优组合”这种场景。这两个问题难度差别很大。采样有比较成熟的高效算法MAP反而是NP-hard的只能靠近似解。我最早天真地以为求出概率最大的子集很容易后来才发现这个问题的组合爆炸本质。不过好在工业界已经有大量近似算法尤其是基于贪心和基于行列式分解的方案效果已经足够好。2.2 标准采样算法从特征分解到条件采样经典的DPP采样算法思路非常优雅分为两步第一步对L矩阵做特征分解得到特征值lambda_i和对应的特征向量v_i。然后构造一个伯努利变量集合每个特征向量v_i以概率lambda_i / (lambda_i 1)被选中。这一步相当于随机选一个特征子空间选中的特征向量张成一个“激活”的子空间。第二步在给定的激活特征向量集合V别名“V集合”下依次对每个候选样本i做条件采样决定是否把i加入最终的集合。这里有个关键性质最终采样得到的子集S留下的特征向量恰好是V中与候选样本张成空间一致的向量被选中的样本i需要满足“当前S中样本与V张成的空间加入i后能够扩展”的条件。具体实现时通常是一种逐点遍历的循环每次按一个条件概率决定是否将该项纳入S。实际工程里很多实现会走“先特征分解再对特征向量强行挑top-k再做子采样”的近似路线因为标准DPP允许的子集大小是随机的而业务上往往要固定数量。固定大小的变体叫k-DPP做法是在所有大小为k的子集上定义分布。k-DPP没有标准DPP直接采样那么方便最常见的近似是先用标准DPP采样出一个集合如果集合大于k就随机截断小于k就再补几个高分项这种处理虽然不严格但胜在简单实测效果也还行。2.3 MAP求解贪心在N选K里往往就够了MAP问题是NP-hard但实际做推荐重排时我们通常只需要组合数为几十选十几这种规模下贪心算法完全够用。最经典的一个贪心是每次从未选项里选一个“当前加入后L_S的行列式增量最大”的样本加进去直到选满k个。但这里有个工程上的坑直接用行列式增量来选择每次都要重新算一遍子矩阵的行列式计算量是O(k·N)次行列式计算而每次行列式计算又是O(k^3)整体代价不低。所以实践中通常会用一种简化形式利用L矩阵和Cholesky分解来高效更新假设当前已选集合S对应的L_S已经完成了Cholesky分解那么加入一个新元素i后行列式的增量可以用Cholesky更新项来快速计算不需要每次重新分解。具体来说L_S分解为M M^T新增节点i后更新项d_i L_ii - ||M^{-1} L_{S,i}||^2。由于Cholesky是下三角矩阵这个更新可以在O(k^2)内完成贪心整体复杂度降到了O(k^2 N)级别几百个候选、选二三十个基本毫秒级出结果。如果不想自己写Cholesky更新也可以直接用特征向量近似对L做特征分解后取前r个主特征向量贪心过程在r维空间里用近似体积增量来选择。这样做精度会有损失但实现简单在很多推荐源码里都能看到这种版本。3. 核矩阵构造真正决定DPP效果的关键3.1 质量分与特征向量怎么配合很多第一次用DPP的同学最大的误区是只关注采样算法和MAP却忽略了核矩阵L本身才是效果的灵魂。L构造得好不好直接决定最终结果质量。按公式L_ij q_i * phi_i^T phi_j * q_j有两个变量要调质量分q和相似度特征phi。质量分q最常见的来源是模型输出的预估CTR、相关性分、置信度等。要注意的是q的尺度对结果影响很大——如果q_i跨度太大比如从0.01到0.99那么质量分对行列式的制约会压倒多样性指标会退化成“几乎按质量排序”。反过来如果q_i都压得很接近多样性权重就会很大结果可能过于发散把低质量项也选进来。我的经验是先把q_i做归一化比如除以全量q的最大值再对q_i做一个温和的缩放比如乘以一个alphaalpha通常在0.5到2之间来控制多样性强度。alpha越大多样性压得越狠。特征phi的选择也很关键。如果直接用原始ID类特征做embedding相似度计算可能过于稀疏如果只用类别特征相似度又会过于粗糙。我的做法是取一个综合向量内容embedding比如文本向量、图像向量、类别或标签的one-hot、有时加上一些业务定义的人工特征最后一并归一化。这样phi_i^T phi_j能同时捕捉语义相似和标签相似比单用内容embedding稳定很多。3.2 相似度矩阵的批量计算技巧假设候选数量N比较大比如N500那么按照公式直接算L矩阵是一个500×500的稠密矩阵内存占用还好500×500的float64约2MB但计算phi_i^T phi_j这一项如果循环写效率会很难看。实际操作我一般直接用矩阵乘法批量算相似度先把所有候选的特征向量堆成矩阵PhiN×d那么相似度矩阵就是Phi.dot(Phi.T)一行搞定。N在几千以内、d在几百以内numpy的矩阵乘法都能轻松处理不需要上GPU。如果N再大可以用faiss或近似最近邻来稀疏化相似度矩阵只保留每个样本的top-K相似邻居把L矩阵变成稀疏矩阵再算行列式速度能快一个数量级。另外我强烈建议在构造L矩阵后做一次半正定检查。因为数值误差或者特征向量归一化不彻底可能会导致L矩阵出现极小的负特征值虽然不影响最终排序太多但在某些严格实现里会导致特征分解报错或结果异常。保险做法是L (L L.T) / 2再在特征分解后把小于1e-10的特征值clip成0。3.3 两个常见变体q-DPP和k-DPP除了标准DPP实际工作中有两个变体几乎必用。第一个是q-DPP也叫质量-多样性DPP它显式地把质量分q_i放在核矩阵里就是我们上面写的L_ij q_i phi_i^T phi_j q_j。这个版本的好处是可以通过单独调节q_i的分布来控制“质量优先”的程度而不是把质量塞进相似度矩阵里。我在电商推荐里经常用q-DPP因为商品质量分预估点击率和商品相似度品类、风格、价格带是两套独立的信号分开建模更可控。第二个是k-DPP前面提过它把所有概率分布限制在大小为k的子集上。k-DPP没有标准DPP那么优雅的采样方式实际工程大多用近似要么标准DPP采样后用启发式补齐/截断到k要么直接对MAP贪心做固定步数限制。在“推荐位固定是10个”的业务里k-DPP几乎是必须的不然标准DPP给个7、8、13个都很尴尬。4. 实操过程与核心环节实现4.1 一套可直接跑的DPP重排流程这里分享一个我在推荐重排里常用的完整流程基于Python依赖numpy不需要额外重框架。第一步准备输入scoresnp.array每个候选的质量分比如模型预估CTRfeaturesnp.ndarrayN×d的候选特征向量建议提前L2归一化k最终要选出的数量alpha多样性调节系数默认1.0第二步构造L矩阵import numpy as np def build_kernel(scores, features, alpha1.0): # scores归一化到[0,1]再乘上多样性缩放系数 q scores / np.max(scores) q q ** alpha # 相似度矩阵 sim features.dot(features.T) # 构造L矩阵 L np.outer(q, q) * sim # 强制对称 L (L L.T) / 2.0 # 加一个小的对角项保证数值稳定 L np.eye(L.shape[0]) * 1e-9 return L注意这里q取幂次alpha而不是乘系数是为了更方便地调节多样性的敏感度。alpha1时质量分被拉伸多样性降低alpha1时质量分差异被压缩多样性增强。实际调参时alpha比线性系数更直观我个人用下来手感更好。第三步用Cholesky更新做贪心MAP求解def dpp_map(L, k): n L.shape[0] items [] chol np.zeros((n, n)) for _ in range(k): best_item -1 best_d -np.inf for i in range(n): if i in items: continue # 计算Cholesky更新项的diagonal value if len(items) 0: d L[i, i] else: # 解三角方程 ci chol[items, :][:, items] li np.linalg.solve(ci, L[items, i]) d L[i, i] - li.T.dot(li) if d best_d: best_d d best_item i if best_item 0: break items.append(best_item) # 更新Cholesky if len(items) 1: chol[best_item, best_item] np.sqrt(best_d) else: ci chol[items[:-1], :][:, items[:-1]] li np.linalg.solve(ci, L[items[:-1], best_item]) chol[items[:-1], best_item] li chol[best_item, best_item] np.sqrt(best_d) return items这段代码在N500、k20时单次耗时在几十毫秒量级可以直接用在低并发场景的实时接口里。如果想更快可以提前把L的特征分解结果缓存起来但要注意业务候选集变化后缓存必须失效——这个我在后面会展开说。第四步和外层业务融合def recommend_with_dpp(scores, features, k, alpha1.0): L build_kernel(scores, features, alpha) selected dpp_map(L, k) # 返回选中的候选下标业务层再按原score排序展示 return selected这里有个我踩过的坑返回的selected下标顺序是DPP算法逐个加入的顺序不代表最终展示顺序。真实业务里通常需要把选出的k个结果再按原始质量分排序一次展示否则可能把质量最高但“多样性贡献大”的项排到第一位用户第一眼看到的相关性反而不够好。4.2 当候选是动态的时候特征分桶与增量计算在推荐场景里候选集几乎永远是动态的——用户请求来了召回结果变特征变分数变。这种情况下每次重新计算L矩阵是最直观的做法但如果候选N很大比如上2000每次重建L再做分解会有一定开销。我试过的一种优化是特征分桶预计算。具体做法是因为内容embedding通常离线算好实时只变质量分q所以相似度矩阵Phi.dot(Phi.T)可以提前算好并且缓存。实时请求只基于当前scores构造q向量再与缓存的相似度矩阵做外积加权即可。这样L的构造成本从O(N^2 d)降到O(N^2)快了一个数量级。如果连O(N^2)都嫌慢可以只用相似度矩阵的top-k邻居稀疏形式做稀疏L下的MAP贪心。另一个动态场景是候选列表里部分项是固定的比如广告坑位DPP只能选择剩余坑位的自然结果。这种情况我的做法是把固定项的索引强制加入已选集合在Cholesky更新时先初始化这些项再对剩余项跑贪心。这样既保证了广告位的固定展示又让自然结果在剩余空间里做多样性优化。4.3 效果评估怎么知道DPP真的起到了作用在做重排优化时如果只盯着离线指标很容易被“多样性提升”迷惑。我一般同时看三类指标子集内部平均相似度把最终选出的k个结果两两算相似度并取平均对比MMR、DPP、纯排序三种方案。这个指标直接反映多样性改善程度。覆盖与曝光指标线上小流量对比时看结果中类目/风格的覆盖率以及长尾内容曝光占比。业务核心指标点击率、转化率、用户深度浏览占比。我实测过的一个案例视频信息流重排里把Top30候选纯CTR排序改成DPP重排k10alpha从0.8调到1.2类目覆盖率从40%左右涨到65%以上同时人均浏览时长提升约8%。点击率基本持平没有因为多样性而明显下降。但也要提醒一句这类收益在不同品类上差别很大内容本身同质化严重的行业比如某些标品电商提升可能很有限。5. 常见问题与排查技巧实录5.1 现象一DPP选出的结果“多样性过强”弱化质量这是我被问得最多的一个问题。现象是选出来的k个结果里有1到2个质量分很低的东西甚至明显不如被丢掉的一些高分项。排查思路先看q_i的分布。如果q_i之间差异太小比如都接近1.0那么DPP会把所有项当成质量相当自然只优化多样性。解决方法是把alpha调小比如从1.2降到0.7或者对q_i做更激进的归一化q_i (q_i / max_q) ^ 2进一步放大质量差异。另外检查一下相似度特征phi里有没有混入“跟质量强相关”的特征比如把预估CTR自己又塞进了phi里那相当于质量被计算了两次模型会过度自信地按质量排序多样性反而崩了。5.2 现象二每次结果重复率高多样性只在个别位次生效有次我在压测时发现DPP输出的结果前面两三个位置几乎不变后面几个位置才在跳动。排查后发现这是因为候选里有两个“全能型”内容——质量分超高的同时和其他内容相似度也高。DPP贪心前两步必然把它们都选进来占掉了多样性空间。这种场景下的解决办法是引入惩罚项或者做“屏蔽重试”如果某个候选与其他已选候选的最大相似度超过阈值比如0.9就在本次迭代里跳过它哪怕它的行列式增量最大。这样相当于给“过强的主导项”加了一重限制实测能显著拉低头部的固定率。不过这种做法会让DPP从严格概率模型变成启发式所以我在实际中会把屏蔽阈值调得保守一点只挡最极端的情况。5.3 现象三候选规模大时特征分解慢如果一个请求的候选N到了5000以上对稠密的L做特征分解会明显变慢numpy里5000×5000的特征分解通常要几十秒甚至更久。标准DPP采样第一步就要特征分解这个瓶颈尤其突出。实测有效的方案是不要对全量N做DPP先按质量分取Top-M比如M200在这个小候选集上做DPP。这样做的合理性在于质量分很低的项本来就不该进入最终结果提前剪枝不损失多样性因为多样性优化只需要在质量合格的范围里做。另一种方案是分块DPP把候选按类目聚类成多个簇每簇内做DPP选subset最后跨簇再选一遍既保证类目多样性又大幅减少单次计算量。5.4 现象四线上效果波动比预期大DPP每次采样是随机的即使用MAP贪心也会因为候选集合微小变化比如召回结果多了一个新item导致最终选择结果跳跃式变化这对线上稳定性是个挑战。我处理这类问题的方式是引入“历史结果锚定”新一次DPP结果和上一次结果之间加入一个混合项比如最终展示集合里保留上次的60%结果剩下40%从DPP新选中补充。这样既维持多样性优化带来的长期收益又避免每次刷新“大变脸”导致用户不适。当然这个比例要A/B测试着调不同业务容忍度差别很大。5.5 常见问题排查速查表现象可能原因处理方法多样性过强质量下降质量分差异被压缩、特征向量里混入质量相关特征减小alpha、加强q归一化、剔除冗余特征结果重复率高个别高分项主导、相似度矩阵过于稀疏加相似度阈值屏蔽、检查特征表达是否太粗糙大候选集计算慢稠密L矩阵特征分解成本高先按质量剪枝到Top-M、分块DPP、稀疏相似度矩阵特征分解报错L矩阵非对称或含负特征值强制对称、加对角小量、特征值clip线上结果跳跃大DPP的随机性或对候选变化敏感历史结果锚定、降低采样随机性、只对差异部分更新6. 一些关于工具选型和后续扩展的实在建议如果项目节奏紧张不想从零写DPP的底层实现可以考虑用现成工具。Python生态里有一个比较轻量的库叫dppy提供了DPP和k-DPP的基础采样实现适合快速验证。但如果要做线上服务我更建议自己用numpy实现一段几十行的贪心MAP加Cholesky更新因为现成库大多面向科研工程化支持有限很难直接嵌入实时服务。我们当时的做法是先拿dppy跑通离线实验验证DPP确实有收益再用自己实现的贪心版本上线。另外DPP和当前大热的LLM结合也有一些值得尝试的空间。我最近在做一个基于语义向量的文档摘要抽取任务把文档的句子embedding作为特征向量预估重要性作为质量分用DPP一次选出8句覆盖不同主题的句子当摘要。相比单纯按重要性Top-8DPP的摘要能明显覆盖到更多子主题读起来信息密度更高。这个思路迁移到图文检索、多模态内容挑选上应该也都行得通。最后再分享一个我自己在踩过不少坑之后形成的习惯每次调DPP参数我不会只调alpha一个值而是会同时跑一版alpha0.7、1.0、1.5的对比分别计算“平均相似度下降多少、核心指标变化多少”。因为多样性这东西靠直觉判断很容易误判只有把指标摆在台面上你才知道DPP到底是在帮你拉新客还是在帮你赶老客。毕竟模型的最终收益永远要以业务指标为准而不是以一个漂亮的行列式数值为准。
返回列表