☰
DPP重排算法:用行列式点过程优雅兼顾推荐多样性与相关性
2026/10/1 9:35:54 网站建设 项目流程

做推荐系统重排序的时候,我遇到过很多次类似的场景:召回阶段拿回来的几百条内容,单独看每条质量分都很高,相关性也没问题,但一排序,前面几页全是同一类内容。用户要么划两下就走,要么觉得系统“猜得太窄”,体验一言难尽。我最早用MMR、用贪心去重、用各种启发式惩罚项去压同质化,效果总是差一口气——不是压制太狠导致相关度崩了,就是压了个寂寞,多样性几乎没有。直到后来组里做视频推荐的同事把行列式点过程(Determinantal Point Process, DPP)甩到我面前,我才第一次意识到:原来“多样性”这件事,可以有一个优雅的概率模型来定义,而且能和“质量”比较自然地融合到一个目标里。

这篇不打算写成教科书,而是按我自己的理解,把DPP从“它到底在解决什么问题”开始,再讲到核心公式背后的几何直觉、实际落地时的核矩阵构造、采样和MAP求解的取舍,最后整理一些我在工程中踩过的坑。如果你也在做推荐重排、主动学习、文档摘要、视频关键帧抽取这类“从一堆候选项里挑一个高相关性又多样化的子集”的需求,这篇文章应该能帮你少走不少弯路。

1. 先搞清楚DPP到底解决什么问题

1.1 从“选一批”到“多样且高质量”的建模

平常我们说的推荐、检索,大多数时候是在做“选一个最优”——给定查询,返回排序列表。但现实中很多需求本质上是“选一批”,而且这一批里既不能都是同一个风格的,也不能为了分散而把最好的几个全扔了。举几个我实际做过的场景:

  • 电商推荐:一个详情页要展示10个相似款,但相似款里如果全是同色同材质同价位的,用户很容易审美疲劳。理想情况是颜色拉开、风格拉开、价格带覆盖高中低。
  • 视频关键帧抽取:一段10分钟视频要取8帧生成封面轮播,如果8帧全是画面亮度、构图类似的静态画面,封面吸引力很差;如果全挑动作最激烈的几帧,可能又过于集中在一个时间段。
  • 主动学习:要从未标注样本里挑一批交给标注人员。只挑模型最不确信的,容易全挑到同一类难例;只挑有代表性的,又可能漏掉真正需要关注的hard case。

这些场景的共同点在于:候选集合里每个元素本身有个体质量分(相关性、置信度、收益),同时元素之间还有相似度,我们希望选出来的子集在“质量总和”与“内部多样性”之间取得平衡。

传统做法里最出名的是MMR(Maximal 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(L+I)是一个归一化常数,保证所有子集的概率之和为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的两个核心问题:采样与MAP

2.1 先分清两个不同任务方向

DPP真正在工程里落地,通常面临两个方向的求解问题。

第一个是采样(Sampling):按照P(Y=S)这个分布去随机抽取一个子集。这在需要“每次结果略有差异”的推荐场景里非常常见,比如推荐流每次刷新想给用户不完全一样但质量都还不错的组合。

第二个是求最大概率子集,也叫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做一个温和的缩放(比如乘以一个alpha,alpha通常在0.5到2之间)来控制多样性强度。alpha越大,多样性压得越狠。

特征phi的选择也很关键。如果直接用原始ID类特征做embedding,相似度计算可能过于稀疏;如果只用类别特征,相似度又会过于粗糙。我的做法是取一个综合向量:内容embedding(比如文本向量、图像向量)、类别或标签的one-hot、有时加上一些业务定义的人工特征,最后一并归一化。这样phi_i^T phi_j能同时捕捉语义相似和标签相似,比单用内容embedding稳定很多。

3.2 相似度矩阵的批量计算技巧

假设候选数量N比较大,比如N=500,那么按照公式直接算L矩阵是一个500×500的稠密矩阵,内存占用还好(500×500的float64约2MB),但计算phi_i^T phi_j这一项如果循环写,效率会很难看。

实际操作我一般直接用矩阵乘法批量算相似度:先把所有候选的特征向量堆成矩阵Phi(N×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,不需要额外重框架。

第一步,准备输入:

  • scores:np.array,每个候选的质量分,比如模型预估CTR
  • features:np.ndarray,N×d的候选特征向量,建议提前L2归一化
  • k:最终要选出的数量
  • alpha:多样性调节系数,默认1.0

第二步,构造L矩阵:

import numpy as np def build_kernel(scores, features, alpha=1.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而不是乘系数,是为了更方便地调节多样性的敏感度。alpha<1时质量分被拉伸(多样性降低),alpha>1时质量分差异被压缩(多样性增强)。实际调参时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

这段代码在N=500、k=20时,单次耗时在几十毫秒量级,可以直接用在低并发场景的实时接口里。如果想更快,可以提前把L的特征分解结果缓存起来,但要注意业务候选集变化后缓存必须失效——这个我在后面会展开说。

第四步,和外层业务融合:

def recommend_with_dpp(scores, features, k, alpha=1.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重排(k=10,alpha从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(比如M=200),在这个小候选集上做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-8,DPP的摘要能明显覆盖到更多子主题,读起来信息密度更高。这个思路迁移到图文检索、多模态内容挑选上应该也都行得通。

最后再分享一个我自己在踩过不少坑之后形成的习惯:每次调DPP参数,我不会只调alpha一个值,而是会同时跑一版alpha=0.7、1.0、1.5的对比,分别计算“平均相似度下降多少、核心指标变化多少”。因为多样性这东西,靠直觉判断很容易误判,只有把指标摆在台面上,你才知道DPP到底是在帮你拉新客还是在帮你赶老客。毕竟模型的最终收益,永远要以业务指标为准,而不是以一个漂亮的行列式数值为准。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询