Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

数据与学习效率:多读一点,还是读对一点

假如要教一个模型认识手写数字,你会给它什么练习?

一种办法是只挑写得最清楚的图片:横平竖直,模型很少犯错。另一种办法是专挑它认不出的图片,希望它尽快补上短板。但第一种可能一直绕开难写的数字,第二种也可能把“标签贴错了”的图片当成特别值得学习的难题。

所以,“这份数据好不好”往往还没问完整。更有用的问题是:对现在这个模型、现在要完成的任务,这份数据能带来什么改变?

这一页会做两组实验。先在真实手写数字上训练模型,比较六种取数方式;再搭一个能逐项算清的生成循环,看看模型反复读自己生成的数据时,哪些内容容易留下,哪些容易消失。

前置内容是数据处理与特征工程、预训练数据工程、模型规模、数据规模与计算预算和后训练方法。资料核查截至 2026 年 10 月 20 日。文中选择六篇代表研究连接数据筛选、课程和合成数据,不作为完整文献清单。

1. 数据效率,先说清楚省了什么

“只用十分之一的数据”很吸引人,但它可能指很不一样的事情。

少了什么仍可能没有减少的成本
不同样本的数量每条样本可能重复训练更多次
训练词元数生成、筛选和给候选打分仍要计算
人工标注量可能改用更强模型生成或审核答案
优化器更新次数每次更新的批量、序列长度可能增加
实际耗时还受设备、批处理、通信和实现影响

例如,从 840 张图里挑出 160 张,重复抽取 7,680 次,和直接从 840 张里抽取 7,680 次,见过的不同图片数有差别,训练样本访问次数却相同。如果挑图之前还训练了一个评分模型,整条流程甚至多做了一些工作。

对文本也一样。一万条短指令和一万篇长文章,不是同样的训练量。比较数据策略时,除了样本数,最好再记下实际词元、重复次数和额外评分成本。

数据筛选还会改变学习目标。假设第 kk 类数据的平均损失为 Lk(θ)L_k(\theta),训练中分给它的比例为 wkw_k,那么混合损失可以写成:

L(θ)=∑k=1KwkLk(θ),∑k=1Kwk=1 L(\theta)=\sum_{k=1}^{K}w_kL_k(\theta), \qquad \sum_{k=1}^{K}w_k=1

θ\theta 是模型参数,KK 是类别或领域数,各个 wkw_k 都非负。调整 wkw_k,就是改变模型把多少学习机会分给各类材料。这里的类别可以是数字 0~9,也可以是网页、代码与数学文本。

如果真实使用中经常遇到的内容,与训练时反复练习的内容不同,总体分数就可能掩盖问题。我们接下来会故意准备一个不均衡的候选池,把这个差别看清楚。

2. 真实实验:六种选法,同样多的学生更新

先把候选池准备好

图片来自 E. Alpaydin 与 C. Kaynak 的 Optical Recognition of Handwritten Digits。我们使用 scikit-learn 随包的 1,797 张 8×8 图片,像素除以数据定义中的上限 16。数据来源与许可为 UCI 原始数据页、CC BY 4.0;本书重新做教学划分,不报告原始 UCI 基准成绩。

划分在训练前固定:

用途图片数在实验中做什么
评分模型训练100每个数字 10 张,学习为候选样本计算损失
候选池840数字 0~7 各 100 张,8、9 各 20 张
开发集200保存表现,未用来挑配置
测试集300最后报告,其中 8、9 合计 59 张
未使用357不进入本次实验

这些集合的图片 ID 没有重叠,但没有按书写者重新分组;因此,本次检验的是这份教学划分中的泛化,不是对全新书写者的严格评测。

为了观察筛选会怎样对待错误标签,我们在候选池中固定改变了 67 个标签,约占 8%。例如,一张原本标成 3 的图可能被改标为 8。后续训练和筛选只看到被修改后的“观察标签”;原始标签另存,用来事后检查选中了多少错标。

这是人为设置的噪声,不是断言 UCI 数据本来就有这些错误。评分模型的 100 张图、开发集和测试集都保留原标签。

“容易”是谁说的

先用那 100 张图训练一个小评分模型。它对候选图片 xix_i 的观察标签 y~i\tilde y_i 计算:

si=−log⁡pϕ(y~i∣xi) s_i=-\log p_\phi(\tilde y_i\mid x_i)

ϕ\phi 是评分模型的参数。分数低,说明评分模型愿意相信这个标签;分数高,说明它觉得意外。意外可能来自难题,也可能来自错标,还可能只是评分模型自己没学好。

我们固定这个评分模型,不随着学生训练刷新分数。随后让结构相同、重新随机初始化的学生模型接受六种数据安排:

方案允许学生抽取的图片
全部候选全部 840 张
随机子集随机抽取 160 张
低损失分数最低的 160 张
高损失分数最高的 160 张
分类低损失按观察标签分组,每个数字选低损失的 16 张
渐进课程与随机子集相同的 160 张,逐步扩大可抽取范围

学生是一个 64 输入、32 隐藏单元、10 输出的网络,中间使用 Tanh,共 2,410 个参数。每种方案都更新 240 次,每次抽取 32 张,即 7,680 次样本访问。使用 Adam,学习率 0.003。

随机种子为 11、22、33。同一种子下,六种方案从相同学生参数开始;种子也影响随机子集。因此,随机方案的三次变化包含初始化与选样差异,而低损失等固定筛选方法的三次子集相同。我们保留全部结果,使用末次模型,没有按照测试成绩换超参数。

评分模型另外训练 200 次、每批 32 张,消耗 6,400 次样本访问,再给 840 张候选图做一次前向评分。随机抽样和“全部候选”本身不需要这份评分。所有实验在 Python 3.12.12、PyTorch 2.14.1、scikit-learn 1.9.1 的 CPU 单线程环境运行。

先看被选中的数据,别急着看分数

选法选中错标数原始类别为 8 或 9 的图片数
全部候选6740
随机子集,种子 111312
低损失00
高损失6714
分类低损失230

低损失筛选看起来很干净:没有选中任何人为错标。但它也把所有 8 和 9 排除在外了。160 张图中有 50 张原本是 0,练习内容明显向某些类别倾斜。

高损失方案则把全部 67 张错标都选了进去,占所选数据的约 41.9%。它很努力地寻找“模型不会的题”,却把标签出错也当成了需要攻克的难点。

“分类低损失”按照观察标签各选 16 张,并不意味着原始类别一定各有 16 张。实际原始类别 8 有 14 张,9 有 16 张;那两处错标仍然留下了。这也是为什么要同时保留筛选依据与事后审查结果。

总分相近,学到的内容却不一样

下面报告测试集上三个种子的平均正确率:

方案总体数字 8、9数字 0~7
全部候选84.7%35.6%96.7%
随机子集82.9%38.4%93.8%
低损失72.2%0.0%89.9%
高损失41.2%23.7%45.5%
分类低损失85.0%91.5%83.4%
渐进课程82.1%28.8%95.2%

图 1:大点是三个种子的均值,小点是各次实测值,线段连接最小值与最大值,并非置信区间。全部候选使用 840 张,其余方案使用 160 张;学生更新次数相同,评分成本另计。

只看总体,84.7% 与 85.0% 很接近,不能据此宣布分类筛选普遍胜出。把类别拆开,变化才显出来:8、9 学得好得多,0~7 却有所退步。这是在有限训练机会下重新分配注意力的取舍。

低损失模型在 8、9 上三次都答对 0/59,与这两个类别没有被选入训练相一致。高损失模型的总体正确数分别为 117、123、131/300,表现最差。困难样本当然可能有价值,但“损失越大越好”在这批混有错标的数据上行不通。

随机子集的总体正确数为 260、241、245/300,波动也很明显。三次训练和一份测试集不足以确定稳定的方法排名;完整的逐图分数与预测都随书保存,不能只挑其中一次讲故事。

还有一笔成本容易漏看:我们已经拥有全部候选标签,并额外使用了 100 张带标签的图训练评分器。因此,选出 160 张只减少了学生可见的不同训练样本,没有证明人工标注成本降到了 160 张,也没有减少本实验中学生的更新计算。

3. 不只看“会不会”,还可以看“往哪学”

前面的低损失、高损失筛选都只看一个标量。另一种思路是问:用这条数据更新一步,会让我们关心的任务变好,还是变差?

这可以与梯度联系起来。记候选样本的损失梯度为 gig_i,一份目标开发数据的平均损失梯度为 gdevg_{\mathrm{dev}}。若进行一次很小的 SGD 更新:

θ′=θ−ηgi \theta'=\theta-\eta g_i

其中 η\eta 是学习率。利用一阶近似,开发损失的变化约为:

Ldev(θ′)−Ldev(θ)≈−η gdev⊤gi L_{\mathrm{dev}}(\theta')-L_{\mathrm{dev}}(\theta) \approx-\eta\,g_{\mathrm{dev}}^\top g_i

右侧的点积就是两组梯度逐项相乘再相加。若点积为正,沿候选样本的负梯度迈一小步,也倾向于降低开发损失;若点积为负,这一步可能朝相反方向走。

用两个坐标算一次

令参数从 θ=(0,0)\theta=(0,0) 开始,开发损失为:

Ldev(θ)=12[(θ1−1)2+θ22] L_{\mathrm{dev}}(\theta) =\frac{1}{2}\left[(\theta_1-1)^2+\theta_2^2\right]

现在的损失为 0.5。候选 A 希望参数靠近 (1,1)(1,1),候选 B 希望靠近 (−1,2)(-1,2),各自也使用“距离平方的一半”作为损失。

在原点,开发梯度为 (−1,0)(-1,0);A 的梯度为 (−1,−1)(-1,-1),B 的梯度为 (1,−2)(1,-2)。它们与开发梯度的点积分别是 +1、−1。

学习率取 0.1。用 A 更新,参数来到 (0.1,0.1)(0.1,0.1),开发损失变成 0.410;用 B 更新,来到 (−0.1,0.2)(-0.1,0.2),开发损失变成 0.625。一阶近似给出的分别是 0.4 与 0.6:方向判断一致,数值仍有近似误差。

A 自己的初始损失为 1,B 为 2.5。只看“谁更难”,会偏向 B;看目标任务上的局部影响,则有不同判断。

这个算例解释了梯度选样的直觉,没有实现一个大模型数据选择器。LESS 将相关思路用于目标能力的指令数据筛选,并处理 Adam 更新、不同回答长度及梯度存储成本,使用 LoRA 和随机投影等办法缩小特征。上面的单步 SGD 点积不能直接冒充完整 LESS。Xia 等,v3,第 2~4 节

这种方法也有边界:局部近似不保证多步训练后的结果;开发集若只覆盖一种任务,选样可能偏向它。用来选数据的开发集也不能再充当最后的独立测试。我们前面的手写数字实验没有采用梯度选样,开发集仅作记录。

4. 课程不是永远从易到难

固定课程:这次并没有赢

本书的“渐进课程”使用与随机子集完全相同的 160 张图,按照评分模型损失从低到高排列:

学生更新可抽取范围
第 1~60 次最容易的 40 张
第 61~120 次最容易的 80 张
第 121~240 次全部 160 张

总更新次数和批量不变,但早期样本会被额外重复。这既改变了样本出现的时机,也改变了它们的实际访问频率,不能把结果归因于纯粹的先后顺序。

结果没有给这条课程带来优势:总体均值为 82.1%,随机子集为 82.9%;8、9 的均值还从 38.4% 降到 28.8%。这不说明课程学习无效,只说明这套固定评分、阶段长度和数据组成没有改善当前实验。

更自然的问题是:模型已经学会容易的部分以后,是否应该重新判断哪些材料值得学?而不是始终用开课前的难度表安排全部学习。

学习空间,和原始损失不是一回事

DoReMi 用较小的参考模型和代理模型估计各领域的混合权重,再把这些权重用于训练较大的模型。它关注的是相对参考模型的损失差,而不只是原始损失最高的领域;论文算法还会将词元级损失差截到非负,再汇总。Xie 等,v3,第 3 节

直觉是:一份文本对谁都很难预测,和参考模型已经会了、当前模型还没会,不一定值得同样的训练投入。参考也有局限,这种差值只是一个有待验证的学习价值信号,不能直接当成数据真伪或知识价值的裁判。

2026 年的一个例子:让模型反馈调整取数

SPaCe 先结合语义表示与难度信息分组,再从各组保留代表样本;训练时,把这些组当成可以尝试的选项,根据答题反馈调整抽取机会,并降低长期停滞组的优先程度。这样把“保留哪些内容”和“此刻练哪一组”联系起来。Do 等,ACL Findings 2026,第 3 节

这里的分组使用额外的嵌入模型,难度信息也有来源成本。论文附录 A.16 的计时明确只报告训练时间,未计入预先估计难度的时间;报告的相对训练时间节省为 2.1%~10.8%。因此,较少的不同训练样本不能直接翻译成同倍数的整条流程加速。论文 PDF,第 4 节与附录 A.16

它的实验围绕数学与逻辑推理,包含不同规模的模型,不能据此认定所有领域都适用。我们的固定阶段手写数字课程没有实现其在线调度,也没有复现其大模型成绩。

5. 合成数据:模型出的题,能不能拿来教模型

前面一直在挑已有图片。假如手里的材料不够,能不能请模型再出一些题?

可以尝试,但“合成数据”这个名字包着好几种做法。先分清楚生成了哪一部分,才容易判断它能补上什么。

做法一个具体例子容易漏看的问题
生成新输入与答案请模型编写打印费用题,并给出计算过程题目可能单一,答案也可能算错
给现有输入补标签为未标注的真实图片预测数字预测标签可能继承模型的偏差
改写或增强输入换一种问法,或对图片做小幅平移改动是否真的保持了原答案?
用规则产生并验证数据随机选页数,用明确公式计算费用模型可能只学会这一种题型

例如,教师模型已经会做一道费用题,学生还不会。让教师写出更多例题,可能把教师已有的能力传给学生。如果生成器本身也不知道某台新打印机的真实速度,再流畅的讲解也不能凭空补出测量结果。

Self-Instruct 给了一个具体的研究路线:从 175 个人工编写的种子任务起步,让模型提出指令、生成输入与输出,再用规则过滤无效或相似的内容,最后用留下的数据微调模型。论文报告了指令遵循能力的改善。Wang 等,v2,第 2~4 节

这里有种子任务、有生成安排,也有筛选步骤。它不能简化成“让模型随便写一大堆,再全部喂回去”。反过来,过滤重复和格式错误,也不等于证明剩下的每个答案都正确。

先想清楚,谁来检查答案

以工坊的打印费用练习为例,可以让生成器提出黑白页数、彩色页数和计价条件,再用独立计算程序检查总价。这样,至少能抓住“题面给出的数与答案不一致”。

但如果题面声称某种材料不收费,算术检查器并不知道这个规定是否真实。算得对与依据可靠,是两件事。真实规定仍需要来自可核对的资料。

覆盖面也要单独观察:如果生成器一直出“10 页黑白、2 页彩色”的换名版本,文件数量会增长,练习内容却没有增长多少。可以按页数范围、是否有固定开销、是否有折扣等条件分组,看看哪些组合始终没有出现。

这些是设计下一组实验时可用的思路,本节没有运行新的大模型出题系统。接下来的实验更小:只生成五种类别中的一个编号。这样可以暂时拿掉语言理解和答案正确性,把“抽样后再学习”本身看清楚。

6. 一个生成循环,为什么会慢慢忘事

先造一个能完全算清的模型

想象有五种工坊咨询:打印、装订、扫描、材料和特殊故障。我们只记录咨询属于哪一类,不生成具体句子。

设定最初的 100 条记录中,五类的数量依次为:

(50, 25, 15, 8, 2) (50,\ 25,\ 15,\ 8,\ 2)

这是本书人为设定的分布,不是实际收集的用户咨询。我们也把它作为希望保留的目标分布:

P=(0.50, 0.25, 0.15, 0.08, 0.02) P=(0.50,\ 0.25,\ 0.15,\ 0.08,\ 0.02)

模型的“学习”很简单:数出每类有多少条,再除以总数。若第 ii 类有 nin_i 条,总数为 NN,就令:

pi=niN p_i=\frac{n_i}{N}

这是有限类别分布的最大似然估计。本实验不加平滑项,也不做梯度下降。模型一次生成一个类别编号,抽到第 ii 类的概率就是 pip_i。

最初的计数被特意设成与目标比例完全相同。这样,后面看到的偏移来自生成循环,而不是因为起点已经估计错了。

每一代都从上一代模型独立抽取 100 条新记录,然后用规定的数据池重新计算比例。重复 20 代,每种方案运行 200 个随机种子,种子为 1000~1199。初始 100 条之后,不再加入新的外部记录。

第一次就可能漏掉罕见类别

特殊故障的初始概率是 0.02。一次没有抽到它的概率为 0.98;独立抽 100 次,一次都没有抽到的概率是:

(1−0.02)100≈0.1326 (1-0.02)^{100}\approx0.1326

也就是说,即便模型完全知道正确比例,一批 100 条样本仍有约 13.3% 的机会没有这类内容。

如果下一代只看这一批新记录,它会把该类概率估计为零。再下一代从零概率抽样,当然也抽不到它。于是,一次偶然漏采就变成了持续缺失。这里没有恶意数据,也没有优化器出错,问题来自有限抽样和丢弃旧记录的组合。

Shumailov 等的研究讨论了递归生成数据中的分布退化,包括罕见部分逐渐消失的现象,并考察了语言模型等不同模型。我们的五类别实验用来解释其中一个机制,不是对自然语言模型的完整复现,也不推出“只要有合成数据就必然失败”。Nature 论文,2024,理论直觉与实验

留下什么,会改变结果

我们比较四种安排。表中的“原始”都指最初那固定的 100 条,没有偷偷补进新观察。

方案每代拟合时保留的数据第 20 代的数据量
只留新生成本轮新生成的 100 条100
累积全部原始数据与历代全部生成数据2,100
原始加本轮原始 100 条与本轮生成 100 条200
累积但偏爱高频同样累积全部,但改变生成时的概率2,100

最后一项故意给常见类别更多机会。若当前模型概率是 pip_i,实际生成时使用:

qi=pi2∑j=15pj2 q_i=\frac{p_i^2}{\sum_{j=1}^{5}p_j^2}

先平方,再归一化,较小的概率就更吃亏。先把五个概率的平方加起来,记作 ss:

s=0.502+0.252+0.152+0.082+0.022=0.3418 \begin{aligned} s&=0.50^2+0.25^2+0.15^2\\ &\quad+0.08^2+0.02^2=0.3418 \end{aligned}

最初 2% 的罕见类,生成概率就变为:

q5=0.022s=0.00040.3418≈0.00117 q_5=\frac{0.02^2}{s} =\frac{0.0004}{0.3418}\approx0.00117

也就是约 0.117%。这项规则只是偏爱高频内容,没有检查内容质量。不能因为生成结果更常见,就称它们更正确。

不只数有没有,还要看比例偏了多少

第 tt 代模型分布记作 PtP_t。我们用总变差距离衡量它与最初目标 PP 的差别:

TV⁡(Pt,P)=12∑i=15∣pt,i−Pi∣ \operatorname{TV}(P_t,P) =\frac{1}{2}\sum_{i=1}^{5}|p_{t,i}-P_i|

pt,ip_{t,i} 是第 tt 代模型给第 ii 类的概率,PiP_i 是目标概率。先算五个概率各偏了多少,把绝对值加起来,再除以 2。完全相同为 0,最大为 1。例如,两类分布 (0.6,0.4)(0.6,0.4) 与 (0.5,0.5)(0.5,0.5) 的总变差为 0.1。

下面是第 20 代、200 次运行的实际结果。“丢失次数”指罕见类的概率变成零的运行数;总变差则比较全部五类的分布:

方案丢失次数平均总变差
只留新生成166/2000.2742
累积全部0/2000.0517
原始加本轮0/2000.0406
累积但偏爱高频0/2000.4451

图 2:每条线都是 200 个种子的均值,比较的是五类别生成模型,不是语言模型成绩。每代都生成 100 条,但用于拟合的数据池大小不同。

“只留新生成”还有一个很容易误读的平均数:罕见类平均概率为 2.2%,看起来甚至比起点的 2% 还高。可实际上,83% 的运行已经把它丢掉了。少数运行给它较大概率,把均值抬了回来。例如,编号最前的种子 1000 在第 20 代给它 22% 的概率;这也不是“准确保留了 2%”。

所以,平均频率接近目标,不能替代对每次运行和每个类别的检查。

“原始加本轮”为何保住了它?原始记录里始终有两条这类内容,每代总数为 200,因此概率至少为 2/200=1%2/200=1\%。本次平均值为 2.000%,但单次运行仍然可能偏离目标,只是不会掉到零。

“累积全部”也保留了那两条,但它们在不断增长的数据池中占比越来越小。第 20 代只能保证概率至少为 2/21002/2100,约 0.095%。按模型原概率生成时,罕见类的平均概率为 1.993%;偏爱高频时则只有 0.105%。后者提醒我们:旧记录还在文件里,不等于它们仍有足够的训练份额。

怎样把小实验与论文接起来

Gerstgrasser 等专门比较了替换旧数据与累积数据,并在语言模型、图像和分子生成实验中观察到不同结果;论文还在特定线性回归模型下推导了累积方案的误差上界。它提醒我们,讨论“模型坍塌”时,要先问清数据如何保留。v2,第 2~4 节

但这不是“把文件全留下就万事大吉”的保证。我们的偏采样反例就保留了全部数据,分布仍越走越偏。它没有否定论文在其设定下的结论,而是改变了生成规则,展示了另一个需要检查的条件。

还要记住三个差别。第一,本实验知道五类的完整名单,只拟合类别频率,不考察语言含义与事实正确性。第二,累积方案的数据池更大;这里通过计数直接求解,不能据此声称神经网络在相同训练时间下也会得到同样优势。第三,总变差本来就不超过 1,不能把“这个距离有界”当作论文中误差上界定理的验证。

如果给每类都加一点平滑计数,可以避免精确的零概率;但这仍不等于恢复了未见过的罕见内容。实际语言模型不仅要知道“还有一种特殊故障”,还得知道故障究竟怎样处理。

7. 自己运行:先核对记录,再重做实验

只用标准库读取结果

在仓库根目录执行:

python3 src/examples/data_efficiency.py

这条命令读取随书保存的真实训练和生成循环记录,重新计算汇总;它不会重新训练 18 个学生模型。结果为:

留出测试均值:总体 / 数字 8 与 9
全部候选:84.7% / 35.6%
随机子集:82.9% / 38.4%
低损失:72.2% / 0.0%
高损失:41.2% / 23.7%
分类低损失:85.0% / 91.5%
渐进课程:82.1% / 28.8%
第 20 代:罕见类概率为零 / 平均总变差
只留新生成:166/200 / 0.2742
累积全部:0/200 / 0.0517
原始加本轮:0/200 / 0.0406
累积但偏爱高频:0/200 / 0.4451
概率 0.02 的类别在 100 次中一次未出现:0.1326

也可以单独下载汇总与循环脚本,在旁边建立 data 文件夹,放入固定方案、全部训练记录和全部循环记录,再执行 python3 data_efficiency.py。

loop_experiment 是第二组实验的完整实现;next_counts 决定留下哪些数据,probability 从计数得到概率。看代码时,先沿这三个函数走一遍。

展开汇总与生成循环代码
"""数据与学习效率:读取筛选实测记录,计算递归生成的分布变化。"""

from argparse import ArgumentParser
from collections import Counter
from hashlib import sha256
import json
from pathlib import Path
import random
import statistics

DATA = Path(__file__).with_name("data")
PLAN = DATA / "data-efficiency-plan.json"
RECORD = DATA / "data-efficiency-training.json"
LOOPS = DATA / "data-efficiency-loops.json"
ARMS = ["all", "random", "easy", "hard", "balanced", "curriculum"]
LABELS = {"all": "全部候选", "random": "随机子集", "easy": "低损失", "hard": "高损失",
          "balanced": "分类低损失", "curriculum": "渐进课程"}
PROTOCOLS = ["replace", "accumulate", "anchor", "accumulate_sharp"]
LOOP_LABELS = {"replace": "只留新生成", "accumulate": "累积全部", "anchor": "原始加本轮",
               "accumulate_sharp": "累积但偏爱高频"}


def digest(path):
    return sha256(Path(path).read_bytes()).hexdigest()


def probability(counts):
    total = sum(counts)
    if total <= 0 or any(n < 0 for n in counts):
        raise ValueError("计数必须非负,总和必须为正")
    return [n/total for n in counts]


def total_variation(p, q):
    return sum(abs(a-b) for a, b in zip(p, q))/2


def next_counts(original, current, generated, protocol):
    if protocol == "replace":
        return generated[:]
    if protocol in {"accumulate", "accumulate_sharp"}:
        return [a+b for a, b in zip(current, generated)]
    if protocol == "anchor":
        return [a+b for a, b in zip(original, generated)]
    raise ValueError(protocol)


def loop_experiment():
    original = [50, 25, 15, 8, 2]
    config = {"original": original, "seeds": list(range(1000, 1200)),
              "generations": 20, "new_samples": 100,
              "protocols": PROTOCOLS, "source_sha256": digest(__file__)}
    runs = []
    for seed in config["seeds"]:
        paths = {}
        for protocol in PROTOCOLS:
            # Shared initial seed, not shared later samples after distributions diverge.
            rng = random.Random(seed)
            current = original[:]
            history = [current[:]]
            for _ in range(config["generations"]):
                p = probability(current)
                if protocol == "accumulate_sharp":
                    p = probability([v*v for v in p])
                samples = rng.choices(range(len(p)), weights=p, k=config["new_samples"])
                counted = Counter(samples)
                generated = [counted[i] for i in range(len(p))]
                current = next_counts(original, current, generated, protocol)
                history.append(current[:])
            paths[protocol] = history
        runs.append({"seed": seed, "paths": paths})
    return {"config": config, "runs": runs}


def loop_summary(record):
    target = probability(record["config"]["original"])
    result = {}
    for protocol in PROTOCOLS:
        rows = []
        for t in range(record["config"]["generations"]+1):
            counts = [run["paths"][protocol][t] for run in record["runs"]]
            distributions = [probability(c) for c in counts]
            rows.append({"generation": t, "n": sum(counts[0]),
                         "tail_zero": sum(c[-1] == 0 for c in counts),
                         "tail_mean": statistics.mean(p[-1] for p in distributions),
                         "tv_mean": statistics.mean(total_variation(p, target) for p in distributions)})
        result[protocol] = rows
    return result


def mean_training_metrics(record, split="test"):
    result = {}
    for arm in ARMS:
        rows = [run["evaluations"][split]["metrics"] for run in record["runs"] if run["arm"] == arm]
        result[arm] = {"accuracy": statistics.mean(x["accuracy"] for x in rows),
                       "tail_accuracy": statistics.mean(x["tail_accuracy"] for x in rows)}
    return result


def main():
    parser = ArgumentParser(description=__doc__)
    parser.add_argument("--run-loops", type=Path)
    args = parser.parse_args()
    if args.run_loops:
        with args.run_loops.open("x") as stream:
            json.dump(loop_experiment(), stream, ensure_ascii=False, separators=(",", ":"))
            stream.write("\n")
        print(f"已保存 200 个种子、4 种协议、20 代的记录:{args.run_loops}")
        return
    record = json.loads(RECORD.read_text())
    assert record["plan_sha256"] == digest(PLAN)
    print("留出测试均值:总体 / 数字 8 与 9")
    for arm, m in mean_training_metrics(record).items():
        print(f"{LABELS[arm]}:{100*m['accuracy']:.1f}% / {100*m['tail_accuracy']:.1f}%")
    loops = json.loads(LOOPS.read_text())
    print("第 20 代:罕见类概率为零 / 平均总变差")
    for protocol, rows in loop_summary(loops).items():
        last = rows[-1]
        print(f"{LOOP_LABELS[protocol]}:{last['tail_zero']}/200 / {last['tv_mean']:.4f}")
    print(f"概率 0.02 的类别在 100 次中一次未出现:{.98**100:.4f}")


if __name__ == "__main__":
    main()

要实际重做 200 个种子的生成循环,在仓库根目录运行:

python3 src/examples/data_efficiency.py --run-loops data-efficiency-loops-new.json

这次会重新抽样、拟合和保存完整的 20 代轨迹。输出路径不能已存在,避免把旧实验覆盖掉。原始五类计数、种子、每代样本数都写在 loop_experiment 中;修改时先另存自己的脚本和结果。

重训手写数字模型

第一组实验需要 PyTorch、NumPy 和 scikit-learn,安装方法可回查工具附录。在仓库根目录,可使用固定的依赖清单运行:

uv run --python 3.12 --with-requirements requirements.txt \
  python src/examples/data_efficiency_train.py \
  --run --out-dir runs/data-efficiency-new

这里使用 CPU,数据由 scikit-learn 随包提供,不下载预训练权重。脚本先核对固定划分,再训练评分模型与 18 个学生模型,保存每步抽取的图片 ID、损失、开发与测试分数、模型文件及其指纹,并重新加载模型核对预测。不同环境的浮点细节可能影响训练结果,比较时也要保留自己的环境记录。

输出目录必须尚不存在。runs/data-efficiency-new/record.json 是新结果,不会自动替换随书的公开记录。

如果只运行训练脚本而不给 --run,默认输出为:

默认不训练。请使用 --run --out-dir runs/data-efficiency-new。
展开手写数字训练代码
"""固定预算的真实 digits 筛选对照;--freeze 保存计划,--run 才训练。"""

from argparse import ArgumentParser
from copy import deepcopy
import json
from pathlib import Path
import platform
import random
import time

import numpy as np
import sklearn
from sklearn.datasets import load_digits
from sklearn.model_selection import train_test_split
import torch
from torch import nn
from torch.nn import functional as F

import data_efficiency as lab


def data():
    x, y = load_digits(return_X_y=True)
    raw = json.dumps({"pixels": x.astype(int).tolist(), "labels": y.tolist()},
                     separators=(",", ":")).encode()
    return torch.tensor(x/16, dtype=torch.float32), torch.tensor(y), lab.sha256(raw).hexdigest()


def make_plan():
    x, y, raw_hash = data()
    pool, test = train_test_split(np.arange(len(y)), test_size=300,
                                 stratify=y.numpy(), random_state=20261020)
    pool, dev = train_test_split(pool, test_size=200, stratify=y[pool].numpy(),
                                random_state=20261021)
    rng = random.Random(20261022)
    selector_ids, candidates = [], []
    for label in range(10):
        ids = [int(i) for i in pool if int(y[i]) == label]
        rng.shuffle(ids)
        selector_ids.extend(ids[:10])
        count = 100 if label < 8 else 20
        assert len(ids) >= 10+count
        candidates.extend(ids[10:10+count])
    selector_ids.sort()
    candidates.sort()
    observed = {str(i): int(y[i]) for i in candidates}
    changed = sorted(rng.sample(candidates, round(.08*len(candidates))))
    for i in changed:
        observed[str(i)] = (observed[str(i)]+rng.randrange(1, 10)) % 10
    assert min(sum(v == k for v in observed.values()) for k in range(10)) >= 16
    return {"version": 1, "date": "2026-10-20", "dataset_sha256": raw_hash,
            "selector_ids": selector_ids, "candidate_ids": candidates,
            "dev_ids": sorted(map(int, dev)), "test_ids": sorted(map(int, test)),
            "observed_labels": observed, "changed_ids": changed,
            "seeds": [11, 22, 33], "arms": lab.ARMS, "subset_size": 160,
            "hidden": 32, "lr": .003, "batch_size": 32, "steps": 240,
            "selector_seed": 20261023, "selector_steps": 200,
            "curriculum_limits": [[60, 40], [120, 80], [240, 160]],
            "selection": "last checkpoint, all arms and seeds; no dev/test selection",
            "corruption": "67 controlled label changes; originals used only for audit/evaluation"}


def model(plan):
    return nn.Sequential(nn.Linear(64, plan["hidden"]), nn.Tanh(), nn.Linear(plan["hidden"], 10))


def train(net, x, labels, ids, plan, seed, steps, curriculum=False):
    generator = torch.Generator().manual_seed(seed+10000)
    optimizer = torch.optim.Adam(net.parameters(), lr=plan["lr"])
    history = []
    for step in range(steps):
        limit = len(ids)
        if curriculum:
            limit = next(n for until, n in plan["curriculum_limits"] if step < until)
        positions = torch.randint(limit, (plan["batch_size"],), generator=generator)
        batch = torch.tensor([ids[i] for i in positions.tolist()])
        loss = F.cross_entropy(net(x[batch]), labels[batch])
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
        history.append({"step": step+1, "pool_size": limit, "ids": batch.tolist(), "loss": loss.item()})
    return history


def choose(plan, observed, scores, seed, arm):
    candidates = plan["candidate_ids"]
    if arm == "all":
        return candidates[:]
    rng = random.Random(seed+20000)
    if arm in {"random", "curriculum"}:
        chosen = rng.sample(candidates, plan["subset_size"])
        return sorted(chosen, key=lambda i: (scores[i], i)) if arm == "curriculum" else sorted(chosen)
    if arm in {"easy", "hard"}:
        direction = 1 if arm == "easy" else -1
        return sorted(candidates, key=lambda i: (direction*scores[i], i))[:plan["subset_size"]]
    if arm == "balanced":
        result = []
        for label in range(10):
            group = [i for i in candidates if int(observed[i]) == label]
            result.extend(sorted(group, key=lambda i: (scores[i], i))[:16])
        assert len(result) == plan["subset_size"]
        return sorted(result)
    raise ValueError(arm)


def metrics(logits, labels):
    z, y = torch.tensor(logits), torch.tensor(labels)
    correct = z.argmax(-1) == y
    tail = (y == 8) | (y == 9)
    return {"n": len(y), "correct": int(correct.sum()), "accuracy": correct.float().mean().item(),
            "tail_n": int(tail.sum()), "tail_correct": int(correct[tail].sum()),
            "tail_accuracy": correct[tail].float().mean().item(),
            "nll": F.cross_entropy(z, y).item(),
            "by_class": [{"label": k, "n": int((y == k).sum()),
                          "correct": int(correct[y == k].sum())} for k in range(10)]}


@torch.no_grad()
def evaluate(net, x, y, ids):
    logits = net(x[ids]).tolist()
    labels = y[ids].tolist()
    return {"ids": ids, "logits": logits, "labels": labels, "metrics": metrics(logits, labels)}


def run(plan_path, out):
    out.mkdir(parents=True, exist_ok=False)
    plan = json.loads(plan_path.read_text())
    assert plan == make_plan()
    torch.set_num_threads(1)
    torch.use_deterministic_algorithms(True)
    x, y, raw_hash = data()
    assert raw_hash == plan["dataset_sha256"]
    observed = y.clone()
    for i, label in plan["observed_labels"].items():
        observed[int(i)] = label
    start = time.monotonic()
    torch.manual_seed(plan["selector_seed"])
    selector = model(plan)
    selector_history = train(selector, x, y, plan["selector_ids"], plan,
                             plan["selector_seed"], plan["selector_steps"])
    with torch.no_grad():
        losses = F.cross_entropy(selector(x[plan["candidate_ids"]]),
                                 observed[plan["candidate_ids"]], reduction="none").tolist()
    scores = dict(zip(plan["candidate_ids"], losses))
    record = {"plan_sha256": lab.digest(plan_path),
              "source_sha256": {p.name: lab.digest(p) for p in [Path(__file__), Path(lab.__file__)]},
              "environment": {"python": platform.python_version(), "torch": torch.__version__,
                              "sklearn": sklearn.__version__, "numpy": np.__version__,
                              "platform": platform.platform(), "device": "cpu", "threads": 1},
              "parameters": sum(p.numel() for p in selector.parameters()),
              "selector_history": selector_history, "candidate_scores": scores,
              "candidate_true_labels": {str(i): int(y[i]) for i in plan["candidate_ids"]}, "runs": []}
    for seed in plan["seeds"]:
        torch.manual_seed(seed)
        initial = model(plan)
        for arm in plan["arms"]:
            selected = choose(plan, observed, scores, seed, arm)
            net = deepcopy(initial)
            history = train(net, x, observed, selected, plan, seed, plan["steps"], arm == "curriculum")
            evaluations = {split: evaluate(net, x, y, plan[f"{split}_ids"]) for split in ["dev", "test"]}
            path = out/f"seed-{seed}-{arm}.pt"
            torch.save({"plan": plan, "state_dict": net.state_dict()}, path)
            restored = model(plan)
            restored.load_state_dict(torch.load(path, weights_only=True)["state_dict"])
            assert all(evaluate(restored, x, y, plan[f"{split}_ids"]) == ev for split, ev in evaluations.items())
            counts = lambda labels: [int((labels[selected] == k).sum()) for k in range(10)]
            row = {"seed": seed, "arm": arm, "selected": selected,
                   "true_class_counts": counts(y), "observed_class_counts": counts(observed),
                   "selected_changed": sum(i in plan["changed_ids"] for i in selected),
                   "history": history, "evaluations": evaluations,
                   "checkpoint": {"file": path.name, "sha256": lab.digest(path), "reload_equal": True}}
            record["runs"].append(row)
            m = evaluations["test"]["metrics"]
            print(f"seed={seed} arm={arm} test={m['correct']}/{m['n']} "
                  f"tail={m['tail_correct']}/{m['tail_n']}", flush=True)
    record["wall_seconds"] = time.monotonic()-start
    (out/"record.json").write_text(json.dumps(record, ensure_ascii=False, separators=(",", ":"))+"\n")
    print(f"完成 18 次学生训练:{out}", flush=True)


def main():
    parser = ArgumentParser(description=__doc__)
    parser.add_argument("--freeze", type=Path)
    parser.add_argument("--run", action="store_true")
    parser.add_argument("--plan", type=Path, default=lab.PLAN)
    parser.add_argument("--out-dir", type=Path)
    args = parser.parse_args()
    if args.freeze:
        with args.freeze.open("x") as stream:
            json.dump(make_plan(), stream, ensure_ascii=False, indent=2)
            stream.write("\n")
        print(f"已冻结数据、噪声与训练设置:{args.freeze}")
        return
    if not args.run:
        print("默认不训练。请使用 --run --out-dir runs/data-efficiency-new。")
        return
    if args.out_dir is None:
        parser.error("需要 --out-dir,且目录不能已存在")
    run(args.plan, args.out_dir)


if __name__ == "__main__":
    main()

这些实验分别回答有限数据池中的筛选问题与有限类别分布的递归问题。它们没有训练一个自然语言合成数据系统,也没有复现 LESS、DoReMi 或 SPaCe 的完整大模型实验。

8. 动手想一想

  1. 学生从 160 张图中抽取 7,680 次,是否说明它的训练计算比使用 840 张图少了约五倍?还需要看哪些成本?
  2. 高损失筛选选中 160 张,其中 67 张是人为错标。错标比例是多少?这能否推出所有难样本都应删除?
  3. 分类低损失在数字 8、9 上明显改善,却在 0~7 上退步。为什么不能只用总体正确率决定是否采用它?
  4. 梯度算例中,A 更新后的开发损失为何是 0.410,而一阶近似是 0.400?多出来的 0.010 来自哪里?
  5. 渐进课程的前 40 张图,在三个阶段中分别以多大概率被抽到?算出其中每张图预期被访问的总次数,再与最后才进入的 80 张比较。
  6. 概率为 0.02 的类别,在 100 次与 200 次独立抽样中一次都没出现的概率分别是多少?增加样本量是否使这个概率严格变成零?
  7. “原始加本轮”中保留 2 条罕见记录,总共 200 条,为什么概率不会低于 1%?如果改为原始 100 条加本轮 900 条,这个保证会变成多少?
  8. 对两类分布 (0.7,0.3)(0.7,0.3) 和 (0.5,0.5)(0.5,0.5) 计算总变差。如果两次实验的罕见类概率分别是 0 与 0.04,平均值恰好为 0.02,能否说明两次都保留了原比例?
  9. 在初始五类分布上,平方再归一化为何会降低罕见类的概率?试着算出最常见类别的新概率。
  10. 如果将来用教师模型生成打印费用题,算术验证器可以检查什么,不能检查什么?应该把最后的测试题放在哪个环节使用?
展开参考答案
  1. 不能。这里网络结构、批量和更新次数相同,学生的样本访问量仍为 7,680。评分方案还增加了评分模型训练与候选前向计算;不同样本数量、人工标注量、词元数与实际时间应分别记录。
  2. 67/160=41.875%67/160=41.875\%。高损失可能来自错标,也可能来自真正需要学习的难题;要结合来源、覆盖和下游表现判断,不能全部删除。
  3. 总体分数把不同类别混在一起。还需要知道真实使用中各类出现的比例、哪类错误代价更大,以及改进能否在独立数据和更多重复中保持。
  4. 更新位移为 (0.1,0.1)(0.1,0.1)。二次损失中还有位移平方的一半,即 (0.12+0.12)/2=0.01(0.1^2+0.1^2)/2=0.01;一阶近似省略了它。
  5. 前 40 张中的每一张,在三个阶段每次抽取被选中的概率分别为 1/401/40、1/801/80、1/1601/160。访问次数期望为 60×32/40+60×32/80+120×32/160=9660\times32/40+60\times32/80+120\times32/160=96;最后 80 张只参与末阶段,每张期望为 24 次。实际次数随机波动,因此课程同时改变了重复权重。
  6. 分别约为 0.1326 和 0.0176。后者更小,但有限次数下仍大于零;下一代的概率也会随本轮结果改变,不能把各代都当作始终固定 0.02。
  7. 没有新罕见记录时,分子仍至少为 2。总数 200 时下界为 1%;总数 1000 时只有 0.2%。保留原始记录的数量与它在训练混合中的权重都重要。
  8. 总变差为 (0.2+0.2)/2=0.2(0.2+0.2)/2=0.2。均值为 0.02 不代表每次都接近 0.02,其中一次已经完全缺失,另一次偏高。
  9. 归一化分母为 0.3418。罕见类变成 0.0004/0.3418≈0.001170.0004/0.3418\approx0.00117,最常见类变成 0.25/0.3418≈0.73140.25/0.3418\approx0.7314;原本较大的概率相对占比进一步增加。
  10. 验证器可以按题面给定规则重算费用,不能单靠算术确认这些计价规则在真实工坊有效,也不能保证题型覆盖。独立测试题应与生成、筛选、调参使用的材料隔离,在方案确定后用于最后评价。

9. 沿着问题继续读

可以先带着三个问题阅读论文:它根据什么挑数据,额外花了什么成本,最后用什么独立证据判断有用?

  1. 哪条数据会帮助目标任务? Xia 等,LESS: Selecting Influential Data for Targeted Instruction Tuning,v3,2024-06-13。原文第 2~4 节依次连接单步影响、实际优化器与梯度特征成本。
  2. 怎样分配领域比例? Xie 等,DoReMi: Optimizing Data Mixtures Speeds Up Language Model Pretraining,v3,2023-10-24。原文第 3 节介绍参考损失、代理模型与最终训练。
  3. 模型怎样生成指令练习? Wang 等,Self-Instruct: Aligning Language Models with Self-Generated Instructions,v2,2023-05-25。先看原文第 2 节的生成与筛选,再读第 8 节的局限。
  4. 反复读生成数据会丢掉什么? Shumailov 等,AI models collapse when trained on recursively generated data,Nature,2024-07-24。对照原文中的数据混合条件、离散例子与语言实验,并阅读2025-03-21 更正。
  5. 保留历代数据会怎样? Gerstgrasser 等,Is Model Collapse Inevitable? Breaking the Curse of Recursion by Accumulating Real and Synthetic Data,v2,2024-04-29。沿原文第 2、3 节及附录 C检查数据保留方式、训练量与理论假设。
  6. 学习过程中怎样调整课程? Do、Nguyen、Venkatesh 与 Le,SPaCe: Unlocking Sample-Efficient Large Language Models Training With Self-Pace Curriculum Learning,ACL Findings 2026。重点读原文第 3、4 节与附录 A.16,分清分组、在线反馈与计时范围。

本页没有给所有数据策略排一个通用名次。手写数字实验提醒我们,筛选会改变模型练习的内容;生成循环则提醒我们,保留与抽样会改变下一代还能看到什么。以后遇到“更少数据也能学得更好”的结果,可以带着这两个问题检查它的具体条件。

回到前沿专题库,还可以继续关注数据来自交互时的新问题:模型采取的行动,会不会改变它下一次能够获得的经验?