质量与多样性的兼得之策
引言
最近做 post-training 筛数据时反复遇到一个需求: 想从一个大池子里挑一个小子集, 既要分高, 又要在某些维度上分散, 比如 repo、tag、语言、时间。
本篇博客主要介绍一下我的思路, 其实把这个看成一个前AI时代的组合优化问题, 想要得到一个优解并没有那么难。数据的多样性不应该是一个"先采样什么什么, 看看分布, 再升降采样"这样的"人类启发式优化", 而是能够给出优性证明的稳定可复现流程。
基于一些数据之类的敏感性, 这个工具不会开源, 让你的Claude Code看完自己复现一个(doge
多样性需求
先说几个的场景, 直观感受一下"质量 + 多样性"到底在说什么:
- PR 池筛 SFT 数据: 1000 万 PR 里挑 1 万, 每条都打过质量分。直接 top-k 的结果是名额大头来自前几个仓库 — 模型只学会"那几个仓库的风格", 对其他仓库基本不泛化
- 多语言代码 SFT: 想要 Python / TypeScript / Go / Rust 比例大致均衡。某些语言的样本质量分普遍高, 直接按分排会几乎全是 Python
- 难度 benchmark 抽样: 简单/中等/困难三档各占 30%, 但模型在中等档输出更长更整齐, 质量分会更高, 直接选会偏到中等
- 去重 + 高分: 选 top-k 之后发现里面有一对 embedding 几乎重合; 或者同一 repo 三天内的两条PR 改了同一个文件
- 覆盖长尾: 数据集有 200 个 task tag, 不希望某些 tag 一条都不选
这些场景的共同特征: 每条数据本身有个分, 但不能只看分。某种"结构"在里面 — 同 repo / 同 tag / 同时间窗 / 同语义簇 — 决定了我们要让候选在这个结构上散开。
把需求抽象一下
把上面这些场景翻译成数学, 我发现它们能整理成三类偏好:
1. 一元偏好 — 单条数据本身的加减分 "带 testing tag 的多加 0.3 分"、"质量分本身"、"主动学习里的不确定度奖励" — 这些都是 per-item 一个 delta, 跟其他条无关。
2. 多元对称偏好 — 同组数量的某种函数 "同 repo 不要太多"、"长尾 tag 至少保 3 条"、"label 比例 4:3:3" — 都是先按某个 key 分组, 然后对每组的"被选中数量"算一个罚或奖。关键在对称: 在同一组里, 哪几条被选中并不重要, 只看选了几条。这个对称性是它能写得简洁的根本原因 — 条数据展开成两两关系是 项, 但因为对称, 折叠成"每组的数量函数"就只跟组数有关。
3. 二元偏好 — 两条之间的关系 "embedding 余弦 > 0.9 的两条不要一起选"、"同 repo 3 天内的两条互斥"、"n-gram 重叠太大互斥" — 这些是真两两的关系, 没办法折叠成"组数量"。
第一类几乎零成本, 直接加进基础分。麻烦的是第二、三类 — 它们都是 上的非线性 / 高阶项, 不做妥协的话求解会爆炸。
设计选择: 哪里做妥协, 为什么是合理的
第一类一元偏好几乎零成本, 直接累加进基础分就行。麻烦的是第二、三类 — 它们都是 上的非线性 / 高阶项, 不做妥协的话求解会爆炸。和 Claude Code 脑暴一番后, 我做了三个非平凡的限制。这些限制让算法可解, 同时我认为它们恰好踩中了业务需求的结构。
限制 1: 二元偏好只支持稀疏图
二元偏好理论上是 的矩阵。如果稠密, , 不管什么算法都跑不动 ( 时矩阵都装不下)。我的限制是 — 平均每条最多跟常数条相关。
业务上其实非常合理: 实际场景里"哪两条要互斥"几乎都来自一些稀疏化的关系 — embedding 的 kNN (每条只跟最近 K 条相关), 时间窗 (每条只跟前后几天相关), LSH bucket (每条只跟同 bucket 的几条相关)。"全 N*N 矩阵"这种在工程上本来也算不出来。
好处: 稀疏二元可以按连通分量切开, 每个分量独立求解 — 用并查集把禁忌图分成若干组, 组与组之间没有耦合。 时大部分分量就一个点, 这一档是 的; 真正费力的只有少数大分量 (典型 ), 用分支定界或小型 LP 处理。
限制 2: 多元偏好只支持组数量的对称函数
多元偏好理论上是 上的一般 set function 。这种太一般, 求解需要枚举所有子集。我的限制是只支持"按 key 分组, 对每组数量算一个标量函数"。
业务上借用了上面"对称性"的观察: 大部分多元需求 ("repo 散开" / "tag 比例" / "长尾覆盖") 在同一组内对哪几条被选中是无差别的。把这种对称结构显式地表达出来, 求解就从 量级降到 量级。
限制 3: 组数量函数限制成凹
"组里选越多越亏"的形状有很多种 — 凸的、凹的、阶梯的、V 形的。我只支持凹的。
这个限制纯粹是为了让对偶松弛紧 — 凹函数有切线上界, 切线斜率可以折进每条数据的有效得分, 组与组之间的耦合就消失了, 拉格朗日松弛紧, ratio 证明成立。
需要 先把"凹"这个词说清楚, 因为它跟"递增递减"是两码事 — 凹 = 二阶差分 = 图像永远不"鼓起来", 跟函数本身是涨是跌没关系。直观说就是 marginal increment 不增。
业务上常见的形状几乎都是凹的:
| 业务含义 | 凹? | |
|---|---|---|
| 二次罚: 每多一条罚得更狠 | ✓ (二阶差分 ) | |
| 饱和罚: marginal 罚趋稳 | ✓ | |
| 双向贴近 (TargetDistribution) | ✓ ( 形抛物线) | |
| 覆盖饱和: 至少 条之后边际收益清零 | ✓ (分段线性) | |
| 熵最大化 | ✓ |
不凹的典型反例: 阶梯硬约束 ( 时 0, 超过给 ) — 在阈值处先平后跌, 中间出现凸弯。
从偏好到算子
把上面的抽象写成代码, 引擎只需要识别三个算子:
| 算子 | 数学 | 用例 |
|---|---|---|
UnaryPreference | "给带 testing tag 的多加 0.3 分" | |
VariancePreference | , 凹 | "同 repo 不要太多"、"label 比例 4:3:3" |
SparseBinaryPreference | , 稀疏 | "embedding 太像的互斥"、"7 天内的互斥" |
总目标:
最大化 , 约束是 (每条要么选要么不选), 总数 。
API 设计
UnaryPreference(name, transform: Dataset → ndarray[N])— 用户写一个函数, 给每条数据返回一个 deltaVariancePreference是一组子类 (Quad / LogSaturating / SoftCap / SoftFloor / HardCap / TargetDistribution), 它们都吃一个grouping: Dataset → dict[key, ndarray[int]]— 用户写一个函数, 把数据按某种规则分组。具体的 形状由子类决定 (二次罚 / 上限罚 / 下限罚 / 双向贴近 target)SparseBinaryPreference(name, transform: Dataset → scipy.sparse.spmatrix)— 用户写一个函数, 返回一个稀疏矩阵, 每个非零项 表示一对禁忌
最关键的设计决定是: 保持solver的抽象层级。怎么按列 group_by, 怎么取某 tag 的布尔掩码, 怎么算时间窗内的 pair — 全是业务决定, 用户在自己自行传入funtion。这样加新约束的时候不用改求解器, 只写一个 transform 函数。
举例:
from selector.rules import Quad, SoftCap, UnaryPreference, SparseBinaryPreference
from selector.solver import Selector
def group_by_repo(ds):
out = defaultdict(list)
for i, r in enumerate(ds["repo"]):
out[r].append(i)
return {k: np.asarray(v, np.int64) for k, v in out.items()}
def tag_boost(tag, delta):
def fn(ds):
return np.where(np.array([tag in (t or []) for t in ds["tags"]]),
delta, 0.0).astype(np.float64)
return fn
res = Selector(
ds, k=700, scores=scores,
preferences=[
UnaryPreference(name="testing_boost", transform=tag_boost("testing", 0.3)),
Quad(name="repo_var", grouping=group_by_repo, alpha=0.05),
],
).solve(max_iter=30)
现有方法都怎么做
- MMR (Maximal Marginal Relevance): 贪心, 选下一条时罚一下"和已选的最像的那条"。简单, 但相似度阈值得自己定。
- k-means + 每簇取 top-1: 先聚类后从每簇挑一个。簇数怎么选是经验。
- DPP (Determinantal Point Process): 用一个核矩阵的行列式建模"diverse subset"。数学上漂亮, 但要把"repo 上分散" / "保留长尾 tag" 这类需求写成一个对称半正定核, Claude觉得不太直观, 而且大规模下计算量较大, 但我直接不会(笑)
- SemDeDup / D4 / DSIR 这类数据筛选方法: 各自针对自己关心的痛点做了优化, 我没用过, 不敢评。
- 基于 submodular 性质的贪心算法 (Nemhauser-Wolsey-Fisher 1978): 给出 的最坏情况下界, 实际表现一般会比这个好。但我关心的目标 (例如 "label 比例 X:Y:Z") 不一定满足 submodular 的条件, 这个下界不一定成立; 即使成立, 我也很难知道这次跑出来的解到底好多少。
我最在意的点是: 没有一个数能直接告诉我"这次选的离最优解差多少"
常见需求示例
| 需求 | 配置 |
|---|---|
| 每个 tag 不超过 X 条 | SoftCap, target = X |
| 同一作者不超过 X 条 | SoftCap, target = X |
| 同一语言不超过 Y% | SoftCap, target = int(k*Y/100) |
| 难度三档各占 ≥ X% | SoftFloor, target = int(k*X/100) |
| 正:负:拒绝 = X:Y:Z (双向贴近) | TargetDistribution, 给每个标签一个目标条数 |
| 选出来的 task_type 跟池子分布一致 | TargetDistribution, 按池子比例算 target |
| 长尾类至 少保 X 条 | SoftFloor, 分组时只输出小组 |
| 余弦相似 > 阈值的两条互斥 | SparseBinary + kNN, 控制边数 |
| 同 repo 7 天内不要共选 | SparseBinary + 时间窗 |
| 信息熵最大 (按某 categorical) | 自定义 VariancePreference, |
| 集合覆盖 (每 tag 至少一条) | LogSaturating 或 |
| ... | ... |
需要注意的几点:
- 比例约束都先乘 转绝对条数, engine 不知道全局比例
- 真的硬约束建议在数据预处理阶段 filter 掉,
HardCap是软强约束 - 跨算子的"如果-那么"逻辑表达不了
- 相对阈值 (top X% by score) 表达不了, 需要在预处理阶段算好布尔列再传进来
理论证明
弱对偶定理: 对任意 ,
其中 , 这里 取遍 (没有"总数 = k"的约束)。
证明很短: 对任意可行解 (满足 ),