开始前,会这些就够了
- 会计算二阶行列式 ad−bc,并知道矩阵由行和列组成;三阶矩阵求逆可留给程序核验。
- 先读 按权重抽两个不同选项,入选概率还是原来的比例吗?,区分集合概率、包含概率与条件概率。
- 先读 向量放大两倍,为什么相似度没变?,理解点积与向量长度。
你有三条候选建议,清单只能放两条。为了让候选更容易被选中,你把所有权重一起提高,却发现每个二元素组合的选择概率完全没变。问题不在随机数,而在你把同一倍率同时乘到了分子和分母。
如果清单可以自由选择长短,同样的调整却可能让它更常选出较长清单。下面从三张卡片的八个可能子集出发,逐项算清楚。本文标为进阶:需要条件概率和小矩阵运算;它是离线数学练习,没有训练推荐模型。
把例子带到自己的电脑上
下载完整练习包
需要 Python 3.10 或更新版本;2026-10-18 实测 Windows / Python 3.14.0,3.10 仅做语法检查。仅用标准库,无第三方依赖。仅使用标准库。实际运行日期、环境和范围见随包核验记录。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。
下载 ZIP · 5 个文件包里有什么
- dpp_scale.py:教学源码
- dpp_scale.observed.stdout.txt:说明或核验记录
- README.md:说明或核验记录
- verification.json:说明或核验记录
- SHA256SUMS.txt:说明或核验记录
在这个文件夹打开 PowerShell,逐行运行
python --version
python -B dpp_scale.py解压到独立练习文件夹,在包含源码的目录打开终端,按下方顺序运行。示例运行记录不代表独立练习已完成;修改代码后需重新验证。
找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。
先把三张卡片写成能手算的小表
把候选叫 A、B、C,用两列人为特征表示:A=(2,0),B=(1,1),C=(0,1)。这些数字只服务于本例,不能解释为真实用户偏好或测得的内容质量。
让表格第 i 行第 j 列等于两个特征向量的点积,就得到 L=[[4,2,0],[2,2,1],[0,1,1]]。例如 A 与 B 的点积为2,A与自身的点积为4。这样的表叫 Gram 矩阵。
接下来不是按某一列从高到低排序,而是给整个候选集合赋权重。集合里只有 A 时取 L 的 A 行与 A 列;集合是 AB 时同时留下 A、B 两行两列。留下的小方阵记作 L_S,其中 S 表示所选集合。
用行列式给整个组合赋权重
本例规定集合 S 的权重为 det(L_S)。二阶行列式的规则是左上乘右下,再减去右上乘左下,所以 AB 的权重是4×2−2×2=4;AC是4×1−0×0=4;BC是2×1−1×1=1。
三个单项 A、B、C 的权重分别是4、2、1。ABC 的权重为0,因为三个向量都放在二维空间中,无法张成三个独立方向。空集的行列式约定为1,这一项不能漏掉。
行列式点过程,简称 DPP,用这种集合权重构造概率。这里采用似然矩阵 L 的写法,要求 L 半正定,以保证每个主子式都非负。向量内积构造能满足这个条件;随手填一个“相似度表”则未必满足。
清单长度自由时,八个集合一起竞争
可变长度的概率是 P(Y=S)=det(L_S)/det(I+L)。Y 是随机选出的集合,I 是单位矩阵。在小例子里,可以直接加上全部集合的权重,核对分母确实等于 det(I+L)。
八个集合分为:空集权重1,三个单项权重和7,三个双项权重和9,三项集合权重0。因此总权重 Z=1+7+9=17。这里有时会选空集,有时选一条或两条,不能把它描述成“总是选两条”。
平均长度是把每个集合的条数乘以它的概率再相加,所以 E[|Y|]=(1×7+2×9)/17=25/17。这个平均数不必是整数;它表示许多次抽取的长度平均,而不是一张清单可以装下半条建议。
固定两条后,同一个倍率会抵消
现在把整个矩阵换成3L。单项行列式乘3,双项乘9,三项乘27。可变长度的总权重变成1+21+81=103,平均长度变成(21+2×81)/103=183/103,比原来的25/17大。
若要求恰好选两条,就只在 AB、AC、BC 三个集合内重新归一化。概率分别是4/9、4/9、1/9。换成3L后,三项权重成为36、36、9,归一化后仍是原来的三个分数。
这种固定数量的条件分布叫 k-DPP。一般地,det(cL_S)=c^|S|det(L_S)。固定 |S|=k 后,所有分子与分母都带 c^k,因此任意正数 c 都会抵消。这个一般结论来自行列式的缩放规则,程序枚举只是核验小输入。
这里严格限定 c>0。c=0 会把核变成零矩阵,可变长度分布只剩空集,固定 k>0 的条件事件概率则为0,不能归一化。负倍率也不属于这个结论。本文的“抬高权重”指整体正比例缩放 L,不是给任意模型分数加常数。
矩阵的对角线还不是最终入选概率
另看一个更小的核 L=[[1,1/2],[1/2,1]]。四个集合的未归一化权重分别为:空集1、只有A为1、只有B为1、AB为3/4。总和15/4,因此 P(空集)=4/15,P(只有A)=P(只有B)=4/15,P(AB)=1/5。
A最终入选包括“只有A”和“AB”两种互斥结果,所以包含概率为4/15+1/5=7/15。它与 L_AA=1不同。不要把候选自身的核值直接读成入选百分比。
包含概率使用另一个矩阵 K=L(I+L)^−1。本例 K=[[7/15,2/15],[2/15,7/15]],K 的对角线才是单项包含概率。更一般地,P(T包含于Y)=det(K_T)。这些 K 公式针对这里的普通可变长度 DPP,不能不加推导就照搬给条件固定 k 的分布。
先验完整输入,不能只看当前要选几条
原型的概率入口只接受至多5项的正方形、对称矩阵;每个值必须是整数或 Fraction 精确分数,布尔值和普通浮点数被拒绝。程序检查所有阶数的主子式是否非负,然后才计算请求的 k。这个穷举校验适合极小练习,不适合大规模生产输入。
反例是 diag(−1,−1):二阶行列式等于1,如果只检查要抽取的k=2,就会误放过它;但两个一阶主子式都是−1,根本不是有效的半正定似然核。程序还拒绝不对称、非正方形以及对角线虽正但存在负主子式的矩阵。
从特征构造 Gram 矩阵时,所有向量必须具有相同且非零的维度。[(1,2),(3,)]会直接报错,不能让 zip 静默丢掉多出的坐标。空候选列表单独允许:k=0 时只有空集,概率为1。
零核也只会选空集。请求零核的k>0,或让k超过可实现的秩,都会因条件事件概率为0而拒绝。原型不是“任意表格都能抽样”的接口,调用者应先检查自己表示的是特征、核值还是概率。
运行完整精确分数实验
下方源码与实际输出一同冻结保存。它遍历三个二维向量、每个坐标取−1/0/1的729个向量矩阵;不同向量矩阵可能给出相同 Gram 核,因此不能声称测试了729个互异核。
主路径通过消元计算行列式,独立参考通过排列展开计算;包含概率再与所有满足包含条件的子集概率之和比较。程序还核对归一化、平均长度、成对关系和正缩放条件分布。
本次实际通过20,958项检查,包含16类非法输入或不可实现请求的逐项拒绝核验,以及5项空集、零核边界检查。所有数字都用精确分数计算,没有运行随机 DPP 采样器或 DPPy。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
"""Small exact DPP experiment. Standard library only; not a production sampler."""
from fractions import Fraction as F
from itertools import combinations, permutations, product
import json
from pathlib import Path
import platform
def determinant(matrix):
a = [list(map(F, row)) for row in matrix]
n = len(a)
value = F(1)
for col in range(n):
pivot = next((r for r in range(col, n) if a[r][col]), None)
if pivot is None:
return F(0)
if pivot != col:
a[col], a[pivot] = a[pivot], a[col]
value = -value
p = a[col][col]
value *= p
for row in range(col + 1, n):
factor = a[row][col] / p
for j in range(col + 1, n):
a[row][j] -= factor * a[col][j]
return value
def determinant_by_permutations(matrix):
"""Independent Leibniz reference, limited to tiny matrices."""
n = len(matrix)
total = F(0)
for perm in permutations(range(n)):
sign = (-1) ** sum(perm[i] > perm[j] for i in range(n) for j in range(i + 1, n))
term = F(sign)
for i in range(n):
term *= matrix[i][perm[i]]
total += term
return total
def inverse(matrix):
n = len(matrix)
a = [list(map(F, row)) + [F(i == j) for j in range(n)] for i, row in enumerate(matrix)]
for col in range(n):
pivot = next((r for r in range(col, n) if a[r][col]), None)
if pivot is None:
raise ValueError('singular matrix')
a[col], a[pivot] = a[pivot], a[col]
p = a[col][col]
a[col] = [x / p for x in a[col]]
for row in range(n):
if row != col:
factor = a[row][col]
a[row] = [x - factor * y for x, y in zip(a[row], a[col])]
return [row[n:] for row in a]
def subsets(n):
return [s for k in range(n + 1) for s in combinations(range(n), k)]
def minor(matrix, subset):
return [[matrix[i][j] for j in subset] for i in subset]
def rational(value):
if type(value) is not int and not isinstance(value, F):
raise ValueError('only exact int/Fraction values are supported; bool and float are rejected')
return F(value)
def valid_kernel(matrix):
"""Tiny exact PSD check: symmetry and ALL principal minors, not only size k."""
if not isinstance(matrix, (list, tuple)) or len(matrix) > 5:
raise ValueError('a list/tuple kernel of size at most 5 is required')
n = len(matrix)
if any(not isinstance(row, (list, tuple)) or len(row) != n for row in matrix):
raise ValueError('square matrix required')
a = [[rational(x) for x in row] for row in matrix]
if any(a[i][j] != a[j][i] for i in range(n) for j in range(n)):
raise ValueError('symmetric matrix required')
if any(determinant(minor(a, s)) < 0 for s in subsets(n)):
raise ValueError('PSD kernel required: a principal minor is negative')
return a
def probabilities(matrix, k=None):
matrix = valid_kernel(matrix)
if k is not None and (type(k) is not int or not 0 <= k <= len(matrix)):
raise ValueError('invalid fixed size')
weights = {s: determinant(minor(matrix, s)) for s in subsets(len(matrix)) if k is None or len(s) == k}
if any(w < 0 for w in weights.values()):
raise ValueError('negative principal minor')
z = sum(weights.values())
if not z:
raise ValueError('requested cardinality has zero probability')
return {s: w / z for s, w in weights.items()}
def scale(matrix, c):
c = rational(c)
if c <= 0:
raise ValueError('the scale-invariance experiment requires c > 0')
matrix = valid_kernel(matrix)
return [[c * x for x in row] for row in matrix]
def gram(vectors):
if not isinstance(vectors, (list, tuple)) or len(vectors) > 5:
raise ValueError('at most 5 feature vectors are supported')
if not vectors:
return []
if any(not isinstance(row, (list, tuple)) for row in vectors):
raise ValueError('feature vectors must be lists or tuples')
dimension = len(vectors[0])
if dimension == 0 or any(len(row) != dimension for row in vectors):
raise ValueError('nonempty feature vectors of equal dimension required')
features = [[rational(x) for x in row] for row in vectors]
return [[sum(a * b for a, b in zip(x, y)) for y in features] for x in features]
def expected_size(distribution):
return sum(len(s) * p for s, p in distribution.items())
def main():
checks = 0
def check(condition, label):
nonlocal checks
checks += 1
if not condition:
raise AssertionError(label)
two = [[F(1), F(1, 2)], [F(1, 2), F(1)]]
p = probabilities(two)
include_a = sum(prob for s, prob in p.items() if 0 in s)
check(p[()] == F(4, 15), 'two-item empty')
check(p[(0, 1)] == F(1, 5), 'two-item pair')
check(include_a == F(7, 15) and include_a != two[0][0], 'L diagonal is not inclusion')
three = gram([(2, 0), (1, 1), (0, 1)])
base, larger = probabilities(three), probabilities(scale(three, 3))
fixed = probabilities(three, 2)
check(expected_size(base) == F(25, 17), 'base expected size')
check(expected_size(larger) == F(183, 103), 'scaled expected size')
check(fixed == {(0, 1): F(4, 9), (0, 2): F(4, 9), (1, 2): F(1, 9)}, 'fixed pair probabilities')
check(fixed == probabilities(scale(three, 3), 2), 'fixed size scale cancellation')
matrices = 0
impossible_fixed_sizes = 0
for entries in product((-1, 0, 1), repeat=6):
vectors = [entries[i:i + 2] for i in (0, 2, 4)]
matrix = gram(vectors)
ss = subsets(3)
for s in ss:
check(determinant(minor(matrix, s)) == determinant_by_permutations(minor(matrix, s)), 'independent determinant reference')
ident_plus = [[matrix[i][j] + F(i == j) for j in range(3)] for i in range(3)]
check(sum(determinant(minor(matrix, s)) for s in ss) == determinant(ident_plus), 'normalizer identity')
dist = probabilities(matrix)
check(sum(dist.values()) == 1, 'normalization')
inv = inverse(ident_plus)
kernel = [[F(i == j) - inv[i][j] for j in range(3)] for i in range(3)]
for s in ss:
included = sum(v for t, v in dist.items() if set(s).issubset(t))
check(included == determinant_by_permutations(minor(kernel, s)), 'inclusion identity')
check(expected_size(dist) == sum(kernel[i][i] for i in range(3)), 'trace expectation')
for i, j in combinations(range(3), 2):
joint = sum(v for t, v in dist.items() if i in t and j in t)
check(joint == kernel[i][i] * kernel[j][j] - kernel[i][j] ** 2, 'pair dependence')
for k in range(4):
try:
fixed = probabilities(matrix, k)
except ValueError:
impossible_fixed_sizes += 1
continue
for c in (F(1, 2), F(3)):
check(fixed == probabilities(scale(matrix, c), k), 'conditional scale invariance')
check(expected_size(probabilities(scale(matrix, F(1, 2)))) <= expected_size(dist)
<= expected_size(probabilities(scale(matrix, 3))), 'size monotonicity on tested grid')
matrices += 1
check(probabilities([], 0) == {(): F(1)}, 'empty ground set k=0')
check(probabilities(gram([]), 0) == {(): F(1)}, 'empty feature set k=0')
zero = [[F(0), F(0)], [F(0), F(0)]]
check(probabilities(zero, 0) == {(): F(1)}, 'zero kernel k=0')
check(probabilities(zero)[()] == 1, 'zero kernel chooses empty set')
check(probabilities(three, 0) == {(): F(1)}, 'nonzero kernel k=0')
rejected = 0
rejection_cases = [
('rank-deficient fixed size', lambda: probabilities([[F(1), F(1)], [F(1), F(1)]], 2)),
('k too large', lambda: probabilities(two, 3)),
('negative k', lambda: probabilities(two, -1)),
('bool k', lambda: probabilities(two, True)),
('negative diagonal hidden by positive 2x2 determinant', lambda: probabilities([[-1, 0], [0, -1]], 2)),
('non-square matrix', lambda: probabilities([[1, 0], [0]], 0)),
('asymmetric matrix', lambda: probabilities([[1, 1], [0, 1]], 1)),
('indefinite matrix with positive diagonal', lambda: probabilities([[1, 2], [2, 1]], 1)),
('unequal feature dimensions', lambda: gram([(1, 2), (3,)])),
('zero kernel k=1', lambda: probabilities(zero, 1)),
('zero kernel k=2', lambda: probabilities(zero, 2)),
('zero scale', lambda: scale(two, 0)),
('negative scale', lambda: scale(two, -1)),
('float kernel unsupported', lambda: probabilities([[0.5]], 1)),
('empty feature dimensions unsupported', lambda: gram([[], []])),
('kernel outside tiny size bound', lambda: probabilities([[0] * 6 for _ in range(6)])),
]
for label, action in rejection_cases:
failed = False
try:
action()
except ValueError:
rejected += 1
failed = True
check(failed, label)
check(rejected == len(rejection_cases), 'all invalid inputs rejected')
report = {
'status': 'PASS', 'runtime': platform.python_version(), 'checks': checks,
'grid_vector_matrices': matrices, 'unique_kernel_count_claimed': False,
'grid': 'Three 2D vectors; every coordinate in {-1,0,1}; 3^6=729 vector matrices, not necessarily distinct kernels.',
'impossible_fixed_size_cases_skipped': impossible_fixed_sizes,
'rejected_input_cases': [label for label, action in rejection_cases],
'input_contract': 'At most 5 items; exact int/Fraction only; square symmetric kernel; every principal minor nonnegative. Gram features must have equal nonzero dimension, except an empty ground set is accepted.',
'scale_contract': 'c > 0 only. At c=0 the variable-size process collapses to the empty set and fixed k>0 is undefined. Negative scales are outside this experiment.',
'main_two_item': {'L_diagonal': '1', 'P_empty': str(p[()]), 'P_pair': str(p[(0, 1)]), 'P_A_included': str(include_a)},
'main_three_item': {'vectors': [[2, 0], [1, 1], [0, 1]], 'E_size_L': str(expected_size(base)),
'E_size_3L': str(expected_size(larger)),
'fixed_k_2': {str(s): str(v) for s, v in probabilities(three, 2).items()}},
'boundary': 'Exact finite arithmetic checks only. No DPP library, random sampler, recommender, training, or model-quality evaluation was run.'}
Path('report.json').write_text(json.dumps(report, indent=2) + '\n', encoding='utf-8')
print('L_11=1; P(A included)=7/15; P(empty)=4/15; P(A,B)=1/5')
print('E[size] at L=25/17; at 3L=183/103')
print('Fixed k=2 at L and 3L: P(A,B)=4/9, P(A,C)=4/9, P(B,C)=1/9')
print(f'PASS: {checks} checks; {matrices} vector matrices; {rejected} rejected inputs')
if __name__ == '__main__':
main()
怎样运行:保存为 dpp_scale.py,在独立练习目录执行 python -B dpp_scale.py。2026-10-18 已在 Windows、Python 3.14.0 实际运行此份源码,生成 report.json。Python 3.10 仅做语法兼容检查,未启动该版本解释器。程序只使用标准库,不访问网络。
运行后对照结果
L_11=1; P(A included)=7/15; P(empty)=4/15; P(A,B)=1/5
E[size] at L=25/17; at 3L=183/103
Fixed k=2 at L and 3L: P(A,B)=4/9, P(A,C)=4/9, P(B,C)=1/9
PASS: 20958 checks; 729 vector matrices; 16 rejected inputs把数学上的不重复和推荐效果分开
行列式会受特征方向与长度影响;在这个表示中,方向重复的组合可能得到较小甚至为0的权重。但特征是否能表达用户在意的差异,是另外一个问题。两条措辞不同但事实重复的建议,未必会被某组特征识别出来。
这个实验能够核对概率账本和输入合同,不能证明内容更有用、更可靠或更公平。若要用在真实推荐里,还需要真实任务的标注与评价;本篇没有做这一步。
继续练习时,先写出你究竟要控制“选多少条”还是“固定数量下选哪些组合”,再选择相应的分布。下方只给改动任务与自检要求,不提前给出新参数的答案。
记住这些要点
- 普通 DPP 同时给清单长度与内容分配概率;固定 k 后只比较相同长度的集合。
- 整体正缩放 L 会对不同长度产生不同倍率,但固定 k 的公共因子会抵消。
- L 的对角线不是最终包含概率;包含事件与恰好选出某集合要分开。
- 输入校验必须覆盖完整核,不能只检查当前 k 阶子式。
别把例子用过头
- 漏掉空集权重,或把可变长度分布误称为固定选两条。
- 把普通 DPP 的 K 包含公式直接套给一般 k-DPP。
- 只看当前 k 阶行列式非负,就接受一个并非半正定的矩阵。
- 把有限精确枚举说成推荐效果或高效采样器的验证。
自己试一小步
把三个特征改成 (1,0)、(1,1)、(0,2),固定选一条与两条时,整体缩放分别会怎样?
- 手算新的 Gram 矩阵以及各个子集的权重,先写下预测再运行。
- 分别比较 L、L
/3、4L 下的可变长度分布,以及 k=1、k=2 的条件分布。 - 再把三个向量都改成同一直线上的向量,检查哪些固定数量请求不再有定义。
做到这些,就算完成
- 每个已定义分布的概率和等于1,包含概率的和与平均长度一致。
- 条件事件权重为0时明确拒绝,没有强行把零权重归一化。
- 区别记录手算结果、代码核验和未验证的实际推荐效果。
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- Determinantal Point Processes for Machine Learning (新标签页)↗
集合归一化、L
/K区别以及固定基数条件分布。 - Finite DPPs — Definition (新标签页)↗
两个概率事件的区别、半正定条件和固定数量的可定义范围。
- Finite DPPs — Properties (新标签页)↗
K=
L(I+L)^−1、平均数量trace(K)、Gram几何及二项负相关式。