评测分数虚高,常常不是模型突然变强,而是提示词、检索库或微调语料里混进了和考题同源的片段。与其再堆看起来更难的题目,不如先用分片指纹把重叠做成可复现的报告和发布门禁。MinHash 适合挡近重复,不能代替真正的分布外评测。
虚高的分数,往往来自同源片段
做模型评测时,最容易被忽略的不是指标公式,而是考题有没有在训练或推理链路里出现过。提示词里贴了刷题站原文,检索库收录了同一份 PDF,微调 jsonl 里混进了评测题的改写——模型只要「见过」,分数就会涨。这种涨幅很难解释:它到底会了推理,还是会了背题?
更麻烦的是,污染很少以「整道题原文」的形式出现。常见形态是:题干被切成检索片段、答案解析被写进 system prompt、同一段官方文档同时进入知识库和评测阅读理解。全文 MD5 对不上,人眼抽查也容易漏。于是团队倾向于换一套「看起来更难」的题,污染路径却原封不动。
去污要解决的不是把榜单做低,而是回答一个更窄的问题:当前评测集,和即将发布的提示词、语料、知识库之间,有没有可复现的近重复重叠。重叠检测过不了,发布就该停,而不是先看分数再找理由。
为什么不用全文哈希,而用分片 MinHash
精确匹配只能抓住复制粘贴。评测污染更多是「同一段文字的子集或超集」:多了题号、少了选项、夹了 HTML、中英文标点被替换。对文本做字符 n-gram 分片(shingle),再比较两个集合的 Jaccard 相似度,比全文哈希更接近「这段话是不是同源」。
直接存所有分片、两两求交,空间和计算都会很快失控。MinHash 的做法是:对每个分片做若干次哈希,每个哈希函数只保留最小值,得到固定长度的签名。两个签名在同一位置相等的比例,用来估计 Jaccard。它估的是集合重叠,不是语义相似;改写、翻译、换变量名的题,本来就不该指望这一层拦住。
分片粒度需要按语言选。中文评测题用「去空白和标点后的字符 5-gram」通常比空格分词稳定。n 太小,模板套话会误报;n 太大,短题干签名不稳定。阈值更不能抄别人的数:用业务里确认的近重复当正例、不同章节的无关段落当负例,看分数分布再定。下面的 0.6 只是可运行的起点,不是推荐值。
还要注意假阳性。许可证全文、指令模板、「请根据以下材料回答」这类样板,会在很多文档里重复。门禁扫描前应先剥掉统一模板,否则报出来的不是题库污染,而是你自己的 boilerplate。
可运行的切片、签名与重叠报告
下面脚本只用 Node.js 内置模块,保存为 overlap-gate.mjs。它读取 jsonl 评测集(字段优先 text / prompt / question / content),扫描语料目录中的 .txt / .md / .jsonl,输出 overlap-report.json,并在有命中时以非零退出码失败。
import { createHash } from 'node:crypto';
import { readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs';
import { join, extname } from 'node:path';
const SHINGLE_N = 5;
const NUM_HASH = 64;
const DEFAULT_THRESHOLD = 0.6;
function normalize(text) {
return String(text).toLowerCase().replace(/\s+/g, '').replace(/[^\p{L}\p{N}]/gu, '');
}
function shingles(text, n = SHINGLE_N) {
const s = normalize(text);
if (s.length === 0) return [];
if (s.length < n) return [s];
const out = new Set();
for (let i = 0; i <= s.length - n; i++) out.add(s.slice(i, i + n));
return [...out];
}
function hash32(str, seed) {
const buf = createHash('sha256').update(`${seed}\0${str}`).digest();
return buf.readUInt32BE(0);
}
function minhash(shingleList, numHash = NUM_HASH) {
const sig = new Array(numHash).fill(0xffffffff);
for (const sh of shingleList) {
for (let i = 0; i < numHash; i++) {
const h = hash32(sh, i);
if (h < sig[i]) sig[i] = h;
}
}
return sig;
}
function estimatedJaccard(a, b) {
let eq = 0;
for (let i = 0; i < a.length; i++) if (a[i] === b[i]) eq += 1;
return eq / a.length;
}
function exactJaccard(a, b) {
if (a.length === 0 && b.length === 0) return 1;
const sa = new Set(a);
const sb = new Set(b);
let inter = 0;
for (const x of sa) if (sb.has(x)) inter += 1;
return inter / (sa.size + sb.size - inter);
}
function loadJsonl(path) {
return readFileSync(path, 'utf8')
.split(/\r?\n/)
.filter(Boolean)
.map((line, idx) => {
const obj = JSON.parse(line);
const text = obj.text ?? obj.prompt ?? obj.question ?? obj.content ?? '';
return { id: obj.id ?? `${path}#${idx}`, text: String(text), source: path };
});
}
function collectFiles(dir, acc = []) {
for (const name of readdirSync(dir)) {
const p = join(dir, name);
const st = statSync(p);
if (st.isDirectory()) collectFiles(p, acc);
else if (['.txt', '.md', '.jsonl'].includes(extname(p))) acc.push(p);
}
return acc;
}
function loadCorpus(root) {
const docs = [];
for (const f of collectFiles(root)) {
if (extname(f) === '.jsonl') docs.push(...loadJsonl(f));
else docs.push({ id: f, text: readFileSync(f, 'utf8'), source: f });
}
return docs;
}
const evalPath = process.argv[2] ?? './eval.jsonl';
const corpusDir = process.argv[3] ?? './corpus';
const threshold = Number(process.argv[4] ?? DEFAULT_THRESHOLD);
const evalDocs = loadJsonl(evalPath).map((d) => {
const sh = shingles(d.text);
return { ...d, sh, sig: minhash(sh) };
});
const corpusDocs = loadCorpus(corpusDir).map((d) => {
const sh = shingles(d.text);
return { ...d, sh, sig: minhash(sh) };
});
const hits = [];
for (const ev of evalDocs) {
if (ev.sh.length === 0) continue;
let best = { score: -1, doc: null };
for (const c of corpusDocs) {
if (c.sh.length === 0) continue;
const score = estimatedJaccard(ev.sig, c.sig);
if (score > best.score) best = { score, doc: c };
}
if (best.doc && best.score >= threshold) {
const shA = ev.sh;
const shB = best.doc.sh;
const overlap = shA.filter((x) => shB.includes(x)).slice(0, 8);
hits.push({
evalId: ev.id,
corpusId: best.doc.id,
corpusSource: best.doc.source,
jaccardEst: Number(best.score.toFixed(4)),
jaccardExact: Number(exactJaccard(shA, shB).toFixed(4)),
evalChars: normalize(ev.text).length,
overlapShingles: overlap,
});
}
}
const report = {
evalCount: evalDocs.length,
corpusCount: corpusDocs.length,
threshold,
shingleN: SHINGLE_N,
numHash: NUM_HASH,
hitCount: hits.length,
hitRate: evalDocs.length ? hits.length / evalDocs.length : 0,
hits,
};
writeFileSync('./overlap-report.json', JSON.stringify(report, null, 2));
console.log(JSON.stringify(report, null, 2));
if (hits.length > 0) {
console.error(`门禁失败:${hits.length}/${evalDocs.length} 条评测样本疑似重叠`);
process.exit(1);
}
console.log('门禁通过:未发现超过阈值的重叠');
本地先放两份最小数据即可跑通:
{"id":"e1","question":"光合作用主要发生在叶绿体的类囊体薄膜上,光反应为暗反应提供ATP和NADPH。"}
语料目录里放一段几乎相同的说明文字,然后执行:
node overlap-gate.mjs ./eval.jsonl ./corpus 0.6
签名用 SHA-256 是为了零依赖、结果可复现,不是因为快。评测集通常只有几百到几千条,和中小语料做全量两两比较可以接受;文档到十万级应改更快的 32 位哈希,并加 LSH 分桶,否则时间会按评测条数乘语料条数涨。NUM_HASH 越大估计越稳、越慢,64 只是演示用的折中。
把门禁接到发布流程,并抽样复核
检测脚本有非零退出码,就可以直接进 CI:提示词仓库变更、知识库同步、SFT 语料合并,都先扫一遍冻结的评测集。报告里至少留下阈值、分片宽度、哈希次数、命中率,以及每条命中的估计/精确 Jaccard。没有这些字段,下周换了分片参数,你无法解释分数为什么变了。
门禁不要只看「有没有命中」。建议同时卡住两类情况:单条超过阈值;命中率超过你能接受的比例。前者抓明显抄题,后者抓大面积同源文档(整本教材进了库)。短于几个分片的题干应单独列表,签名不可靠,不能算通过也不能算污染,交给人工。
抽样复核是门禁的一部分,不是事后点缀。从命中里按分数分层抽:高分近重复应能在原文里对上连续片段;卡在阈值附近的要看是题干重叠还是选项模板重叠。脚本已经给出若干 overlapShingles,复核时对照两端原文,确认不是页眉页脚或许可协议。复核结论写回报告:确认污染、误报、待定。只有确认污染的样本才从训练/检索侧删除或从评测集剔除;不要让模型「见过答案」之后再假装这是零样本。
发布记录里应固定三件事:评测集版本哈希、语料/提示词版本、这次门禁报告。否则三个月后分数上涨,你无法证明中间没有渗入同源文本。
去污能挡住什么,不能代替什么
MinHash 重叠门禁能挡住的,主要是近重复:同一段落的裁剪、标点与空白扰动、题干被包进检索 chunk、解析文被写进 prompt。它给出的是集合重叠的可复现估计,适合做发布闸门,不适合当「数据干净了」的证明。
它拦不住的同样清楚。同义改写、翻译后再译回、数字和实体被替换、代码题改了变量名,分片集合会散掉,Jaccard 可以很低。语义检索把「很像的题」送进上下文,也不在 MinHash 的定义里。若污染发生在预训练网页里,而你只能扫描自己的 SFT 和 RAG 库,门禁范围以外的泄漏仍然存在。
因此去污之后,仍需要真正的分布外评测:更新时间切分、不同来源、不同题型,以及你训练数据里系统性缺失的任务。把旧题改得更绕,却仍与语料同分布,只是换了一种虚高。重叠检测解决的是「别用漏题考自己」;模型会不会迁移,要另开一套题、另一套协议。
操作上保持克制:阈值宁可不发布,也不要为了过 CI 把阈值抬到 0.95——那等于只拦全文复制。假阳性用模板剥离和抽样复核消化,而不是关掉门禁。发现污染时,优先从提示词和检索库移除,而不是从评测集删题;评测集一删,你就失去了追踪泄漏的锚点。
总结
评测被题库污染时,先做分片指纹,再谈换更难题。用字符 n-gram 加 MinHash,可以把提示词、语料、知识库与冻结评测集的近重复重叠,收成带阈值、签名参数和命中明细的报告,并在 CI 里一票否决。精确 Jaccard 与分片抽样用来消化误报;短文本和模板重复要单独处理。这套门禁挡的是同源近重复,不是改写,更不是分布外能力。分数在去污之后仍然上涨,才比较像学会了题,而不是见过了题。
评论