📑 本页目录(点开跳转)
07 · 评测的坑:随机划分在这里是作弊
⏱ 32 分钟 | ⭐ 同一份数据同一个模型,只改划分方式,R² 从 0.71 掉到 −1.20
🎯 一句话
随机划分训练集和测试集,在科学数据上几乎总是错的。 互联网数据里样本大致独立;这里不是 —— 同一个家族的序列彼此高度相似, 随机划分会把「几乎一样的东西」同时放进两边,于是模型只要记住就能拿高分。
🧬 一、为什么这里的样本不独立
蛋白质序列数据库里,大量条目是同源的:来自不同物种、但由同一个祖先演化而来, 序列相似度常常在 60%–90% 以上。化合物库同理 —— 一个母核衍生出几十个类似物。
⚠️ 这不是数据脏,这是数据本来的样子。数据库收录的就是「已经被研究过的那些」, 而研究本身就倾向于成簇进行:找到一个有意思的蛋白,接着测它的一堆同源物。
于是你手上那一万条数据,有效独立样本数可能只有几百。
⭐ 一句话判据:如果随便抽两条样本,它们相似到「知道一条就能猜出另一条的标签」, 那这两条就不该被划分到两边去。
🧪 二、把这件事跑出来
下面这段用合成数据复现这个坑:40 个「家族」,每族一条原型序列, 族内成员是原型的小扰动(12% 位点突变)。标签由家族原型决定 —— 也就是说真信号在家族层面,不在单条序列层面。
import numpy as np
rng = np.random.default_rng(0)
N_FAMILY, PER_FAMILY, L, K = 40, 25, 60, 20 # 40 个家族,每族 25 条,长 60,20 种"氨基酸"
# 每个家族一条原型;同族成员是原型的小扰动 —— 这就是"同源"
proto = rng.integers(0, K, size=(N_FAMILY, L))
seqs, fam = [], []
for f in range(N_FAMILY):
for _ in range(PER_FAMILY):
s = proto[f].copy()
mut = rng.random(L) < 0.12
s[mut] = rng.integers(0, K, size=mut.sum())
seqs.append(s); fam.append(f)
seqs, fam = np.array(seqs), np.array(fam)
# 标签由家族原型决定(真信号在家族层面),加一点噪声
w = rng.normal(size=(L, K))
fam_y = np.array([w[np.arange(L), proto[f]].sum() for f in range(N_FAMILY)])
y = fam_y[fam] + rng.normal(0, 0.35 * fam_y.std(), size=len(fam))
X = np.zeros((len(seqs), L * K)) # one-hot
X[np.arange(len(seqs))[:, None], np.arange(L) * K + seqs] = 1.0
def ridge_r2(tr, te, lam=1.0):
Xt, yt = X[tr], y[tr]
beta = np.linalg.solve(Xt.T @ Xt + lam * np.eye(X.shape[1]), Xt.T @ (yt - yt.mean()))
pred = X[te] @ beta + yt.mean()
return 1 - ((y[te] - pred) ** 2).sum() / ((y[te] - y[te].mean()) ** 2).sum()
idx = rng.permutation(len(seqs))
cut = int(len(idx) * 0.75)
r2_random = ridge_r2(idx[:cut], idx[cut:]) # ① 随机划分
fam_order = rng.permutation(N_FAMILY) # ② 按家族划分
train_f = set(fam_order[:int(N_FAMILY * 0.75)])
tr = np.array([i for i in range(len(seqs)) if fam[i] in train_f])
te = np.array([i for i in range(len(seqs)) if fam[i] not in train_f])
r2_group = ridge_r2(tr, te)
print("同一份数据、同一个模型,只改划分方式:")
print(" random split test R2 = %.3f" % r2_random)
print(" group split test R2 = %.3f" % r2_group)
print(" inflation %.3f" % (r2_random - r2_group))
in_train = np.array([np.isin(fam[i], fam[idx[:cut]]) for i in idx[cut:]])
print("random split: %d%% of test seqs have a same-family sibling in train"
% (100 * in_train.mean()))
真实输出:
同一份数据、同一个模型,只改划分方式:
random split test R2 = 0.707
group split test R2 = -1.199
inflation 1.906
random split: 100% of test seqs have a same-family sibling in train
⭐ R² = 0.707 看起来是个像样的模型;R² = −1.199 意味着它还不如直接猜训练集均值。
同一份数据、同一个模型、同一套超参 —— 只改了怎么切。
⚠️ 你的数值会不同,要看的是两者的差距。 我换了六组设置复核过: 正则化从 1 加到 100、家族数从 40 加到 100、突变率从 5% 到 25%、换随机种子 —— 随机划分的 R² 恒在 0.66–0.85 之间,按家族划分的 R² 恒为负数(−0.25 到 −1.36)。 ⭐ 这个差距不是某组超参下的巧合。
🚨 三、为什么正则化救不了它
一个自然的反应是「加大正则化就好了」。上面那组复核里,
把 lam 从 1 加到 100,按家族划分的 R² 从 −1.20 抬到 −0.25 —— 抬上去了,但还是负的。
⭐ 原因不在模型复杂度,在于训练集里根本没有测试家族的信息。 标签由家族原型决定,而那 10 个测试家族的原型模型一次都没见过。 正则化能防止过拟合,防不了「要预测的东西压根不在训练分布里」。
⚠️ 所以看到「按组划分后指标暴跌」时,不要第一时间去调模型 —— 先接受这个数字, 它才是你系统真实泛化能力的估计。
🔧 四、正确的划分怎么做
- 先聚类,再划分。 按序列相似度把数据聚成簇,整簇整簇地分配到训练/验证/测试。 🗓️ 工具层面常用 CD-HIT、MMseqs2 这类序列聚类程序(版本和参数会变,用之前查它们的当前文档)。
- 阈值要说清楚。 「30% 相似度以下才算不同簇」和「90% 以下」是两个完全不同的难度, ⭐ 报告指标时必须同时报这个阈值,否则数字之间不可比。
- 验证集也要按组切。 只把测试集按组切、验证集还是随机切,调参那一步照样会泄漏。
- ⚠️ 用别人的公开划分之前,先问它是怎么划的。 公开 benchmark 一样可能带这个坑。
⭐ 这不是一条新规矩,是站内那条规矩换了一个域。 数据这一关 · 14 数据泄漏的七种来源 的 ④ 分组泄漏讲的就是这件事:同一个用户的多条记录被随机切散, 随机切 AUC 0.706、按用户
GroupKFold切只剩 0.564 —— 那 0.14 是「认人」不是「预测」。⭐⭐ 唯一的区别是「组」给没给你:那边
user_id是现成的一列, 一行len(set(uid_train) & set(uid_test))就能验,期望是 0;而这里没有这一列 —— 「哪些序列算同一组」得你自己按相似度聚出来, 连阈值定在哪都是你的决定。这才是科学数据上真正难的那一半。
🪤 五、另外三个坑
只有成功的实验进了数据库
发表和录入本身是有筛选的:做出来没效果的分子、表达失败的突变体,大多不会进公开库。 ⚠️ 于是你的「负样本」不是真正的负样本,而是没被记录的那一类被整体删掉了。
⭐ 这就是数据这一关 · 15 采样偏差与幸存者偏差 讲的那件事:你手上的数据只包含「活下来的」那部分,而失败的那部分根本没进样本。 用它训出来的模型会系统性高估效果,而且从数据本身看不出问题。
测试集太小,指标根本分不出差别
科学数据的测试集常常只有一两百条 —— 而按家族划分之后,有效独立样本数还要再打一个折。 ⚠️ 站内大模型全景导论 · 11 评测体系算过一笔同类的账: 100 题只能分辨约 8 个百分点,想分辨 3 个点要约 700 题。 ⭐ 你的测试集如果只有 40 个独立家族,「A 比 B 高 2 个点」这句话大概率没有意义。
指标选错
阳性样本占 3% 时,一个「全都预测阴性」的模型准确率就有 97%。 ⭐ 极不平衡的数据上看准确率没有意义,该看的是 AUPRC 这类对正类敏感的指标, 或者干脆报「排名前 N 里有几个真阳性」—— 因为下游真正要做的就是「挑 N 个去做实验」。
🔗 这一章连到哪里
| 去哪 | 为什么 |
|---|---|
| 数据这一关 14 · 数据泄漏的七种来源 | ⭐ 它的 ④ 分组泄漏和这一章是同一个机制:那边同一个用户的记录跨集,随机切 AUC 0.706、GroupKFold 按用户切只剩 0.564。⭐⭐ 差别在「组」是不是现成的 —— 那边 user_id 是一列,一行 len(set(uid_train) & set(uid_test)) 就能验;这里没有这一列,「哪些序列算同一组」本身就是个要你来定的建模决策 |
| 数据这一关 15 · 采样偏差与幸存者偏差 | 「只有成功的实验进了数据库」的完整版,以及查出偏了之后的逆倾向加权 |
| 数据这一关 13 · 评测集也是数据 | ⭐ 尺子本身也会坏。它的第三节「多大才够:把置信区间算出来」,正是「测试集太小、指标分不出差别」的算法 |
| 机器学习与深度学习基础 05 · 评估与过拟合 | 交叉验证的基本功。⚠️ 这里要用的是按组的交叉验证,不是普通的 K 折 |
✅ 检查点
- 为什么科学数据上的随机划分是作弊?用一句话说清机制。
- 实验里两种划分的 R² 各是多少?「负的 R²」意味着什么?
- 加大正则化能不能救回按家族划分的指标?为什么?
- 正确的划分要做哪四件事?为什么验证集也要按组切?
- 「只有成功的实验进了数据库」会造成什么后果?它对应站内哪一章讲的问题?
- 测试集只有 40 个独立家族时,「A 比 B 高 2 个点」这句话可信吗?
👀 答案
- 同源序列高度相似,随机划分会把几乎一样的样本同时放进训练集和测试集,于是模型只要记住训练集就能在测试集拿高分 —— 测的是记忆不是泛化。实验里随机划分下 100% 的测试序列在训练集里有同族兄弟。
- 随机划分 R² = 0.707,按家族划分 R² = −1.199,虚高 1.906。负的 R² 意味着模型还不如直接猜训练集均值。
- 不能。
lam从 1 加到 100,按家族划分只从 −1.20 抬到 −0.25,仍然是负的。因为问题不在模型复杂度,而在于训练集里根本没有测试家族的信息 —— 正则化防得了过拟合,防不了「要预测的东西不在训练分布里」。 - ①按相似度聚类再整簇分配 ②报告时必须同时报相似度阈值 ③验证集也要按组切(否则调参那一步照样泄漏)④用别人的公开划分前先问它是怎么划的。
- 负样本不是真正的负样本,而是「没被记录的那一类被整体删掉了」,模型会系统性高估效果、而且从数据本身看不出来。对应数据这一关 15 · 采样偏差与幸存者偏差。
- 大概率不可信。 站内算过:100 个独立样本只能分辨约 8 个百分点,想分辨 3 个点要约 700 个。40 个家族比 100 还少。
🛑 可以停在这里
读到这里你手上有一条可以立刻执行的规矩:拿到任何科学数据集,第一件事是问「它是怎么划分的」 —— 如果答案是「随机」,那所有报告出来的指标都要重新估。
⚠️ 什么时候看下一页:你已经能正确评估了,接下来要决定下一批实验做什么 —— 那是把评测接回数据生产的事。
⚡ 走神救援
先记住这几件事
- 随机划分可能把近亲样本分到两边,分数高不等于能推广到新家族。
- 划分规则要对应实际外推目标,验证集也必须使用相同原则。
- 检查成功实验偏差、测试规模和任务指标,并把这些限制与结果一起报告。
下一节 👉 08-数据实验模型飞轮.md