承接与场景
上篇预算表帮设备团队定了路线:13B 走 QLoRA。栈选好了,真正决定天花板的东西才登场——训练数据。垂直领域数据工程最容易犯的错,是把"条数够多"当成"像真实业务":造了三万条样本,分布却是拍脑袋的比例,上线第一天就被真实流量的长尾打爆。本篇用两个确定性模拟回答两个具体问题:多个类目、池子大小悬殊的数据,一个 epoch 内到底该怎么配比?用模板批量合成时,多样性在哪个环节塌缩?场景仍是那家工业设备厂商,这次聚焦它们的故障报修语料。
垂直数据与通用数据的两处差别
第一处是事件分布。真实业务里"改地址"和"爆炸申诉"的出现频率差着数量级,训练集如果各类目拉平,模型对高频事件过度熟练、对低频事件反而陌生——低频高危事件(安全红线)恰恰是最不能出错的。正确做法是先从工单系统统计真实事件频率,让采样分布对齐线上分布,再对高危类目做有上限的过采样(后面实验给出算法)。第二处是行为规格。通用指令的数据集没有"正确答案的写法",垂直业务有:报修话术必须包含"确认停机→询问型号→给排查步骤→生成工单"的 SOP 结构,漏一步就是事故。这类规格没法从公开语料里蒸馏,必须让业务专家定义模板、由模板反向约束合成与改写流程——这也是蒸馏数据需要重写的根本原因:强模型不知道你的 SOP。
数据来源构成一个金字塔:塔基是强模型蒸馏(便宜、量大、分布失真),塔身是存量工单改写(分布真实、需要脱敏与规范化),塔尖是专家构造(数量少、定行为规格、必须进评测集)。健康的垂直数据集是三层混着按比例喂,纯任何一层都有系统性偏差。
实验一:配比的水位分配算法
设备团队的五个类目池子悬殊:手册 QA 四万二、故障工单三千、备件查询八百、通用指令两万、安全红线案例只有一百二。目标是按温度 α=0.5 把大池子向小类目倾斜(份额 ∝ n^α,介于自然分布与均分之间),同时给过采样设硬上限——一个 epoch 内任何类目最多重复 4 轮,否则模型只是在同批样本上多走了几圈,背题而非学习。份额超限时用"水位法"把溢出量按比例让给未饱和类目:
"""训练数据配比: 自然分布 -> 温度重加权 -> 重复上限水位分配, 确定性。"""CATS={# 类目: 池内条数"设备手册QA":42000,"现场故障工单":3000,"备件与库存查询":800,"通用指令(防遗忘)":20000,"安全红线案例":120,}BUDGET=30000# 一个 epoch 喂给优化器的条数CAP=4# 单类目一个 epoch 内最多重复轮数ALPHA=0.5# 温度: 1=自然分布, 0=各类目均分deftemp_shares(alpha):w={k:n**alphafork,ninCATS.items()}s=sum(w.values())return{k:v/sfork,vinw.items()}defwater_fill(shares):"""按目标占比分配, 超过 CAP x 池容量的份额按比例让给未饱和类目。"""alloc={k:0.0forkinCATS}pool,budget=dict(CATS),float(BUDGET)for_inrange(10):live={k:shares[k]forkinpool}s=sum(live.values())given={k:budget*v/sfork,vinlive.items()}ifall(given[k]<=CATS[k]*CAP+1e-9forkingiven):alloc.update(given)breakforkinlist(pool):ifgiven[k]>CATS[k]*CAP+1e-9:alloc[k]=CATS[k]*CAP budget-=alloc[k]delpool[k]ifnotpool:breakreturnalloc natural={k:n/sum(CATS.values())fork,ninCATS.items()}target=temp_shares(ALPHA)alloc=water_fill(target)print("%-14s %7s %8s %8s %8s %8s"%("类目","池容量","自然占比","温度占比","实配条数","重复倍数"))forkinCATS:a=alloc[k]print("%-14s %7d %7.1f%% %7.1f%% %8.0f %7.1fx%s"%(k,CATS[k],natural[k]*100,target[k]*100,a,a/CATS[k]," <-- 顶到上限"ifa>=CATS[k]*CAP-0.5else""))tot=sum(alloc.values())print("实配合计 %.0f 条 (预算 %d, 饱和让渡后未用满 %s)"%(tot,BUDGET,"0"ifabs(tot-BUDGET)<1else"%.0f"%(BUDGET-tot)))运行输出:
类目 池容量 自然占比 温度占比 实配条数 重复倍数 设备手册QA 42000 63.7% 46.5% 14088 0.3x 现场故障工单 3000 4.6% 12.4% 3765 1.3x 备件与库存查询 800 1.2% 6.4% 1944 2.4x 通用指令(防遗忘) 20000 30.3% 32.1% 9722 0.5x 安全红线案例 120 0.2% 2.5% 480 4.0x <-- 顶到上限 实配合计 30000 条 (预算 30000, 饱和让渡后未用满 0)这张表把配比的三重张力全暴露了。温度 α=0.5 把"备件查询"从自然占比 1.2% 提到 6.4%、安全红线从 0.2% 提到 2.5%——这是小类目获得存在感的方式;同时"设备手册"从 63.7% 压到 46.5%,且实际只取用池子的 0.3 倍(大量池子根本喂不完,这时候该做的是扩充类目多样性而不是堆量)。最关键的是最后一行顶到上限的"安全红线":按目标份额应喂约 746 条,但池子只有 120 条、4 轮重复封顶 480 条——水位算法诚实地暴露了"数据缺口"这个真问题:想让模型多见安全案例,正确反应是请专家补造至少 70 条新案例再吃 4 轮重复,而不是把重复倍数调到 20。工程上这份分配表就是 DataLoader 的 weights 参数(PyTorch 的 WeightedRandomSampler 能直接吃),每轮训练前重算一次并归档,配比从此有据可查。
实验二:模板合成数据的熵上限
模板生成是塔尖规格下沉到塔基的通道:把 SOP 写进句式模板,槽位从业务枚举里取。它的隐患是组合空间被严重高估。设备团队有 12 个部件 × 12 种症状的槽位,看着 144 种变化,实际能产出多少不重复的问法,取决于模板条数。生成 3000 条,比较不同模板数下的唯一句数与 3-gram 熵(理论唯一数用生日碰撞公式核算):
"""模板化合成数据的多样性塌缩: 模板数决定熵上限, 确定性模拟。"""importmathimportrandom COMPONENT=["液压泵","主轴","刀库","冷却泵","伺服电机","编码器","丝杠","导轨","夹具","变压器","传感器","润滑油路"]SYMPTOM=["异响","过热","报警E-04","精度漂移","漏油","无法启动","抖动","间歇停机","压力不足","通信中断","限位误报","刀补失效"]TEMPLATES=["客户反馈设备{c}出现{s},请给出处理建议。","{c}{s},怎么排查?","急!{c}又{s}了,现场催得紧。","请问{c}的{s}问题严重吗,需要停机吗?","{c}最近{s},之前换过一次件,这次怎么处理?","报修:{c}{s},附报警代码。","新手提问,{c}{s}一般是什么原因?","{c}维护记录显示有过{s}历史,现在复发了。","夜班遇到{c}{s},先做什么应急处理?","{c}{s},客户要求 2 小时内恢复,方案?","{c}的{s}能不能在线解决,不去现场行不行?","对比上次故障,这次{c}{s}有什么不同,如何处理?",]M=3000# 每个配置的合成条数defmake(n_tpl):rng=random.Random(7)return[TEMPLATES[rng.randrange(n_tpl)].format(c=rng.choice(COMPONENT),s=rng.choice(SYMPTOM))for_inrange(M)]defentropy(samples):cnt={}forxinsamples:foriinrange(len(x)-2):g=x[i:i+3]cnt[g]=cnt.get(g,0)+1tot=sum(cnt.values())ent=-sum(c/tot*math.log2(c/tot)forcincnt.values())top=max(cnt.values())/totreturnlen(set(samples)),ent,topprint("槽位组合上限 = 模板数 x %d x %d"%(len(COMPONENT),len(SYMPTOM)))forn_tplin(1,2,4,12):uniq,ent,top=entropy(make(n_tpl))combos=n_tpl*len(COMPONENT)*len(SYMPTOM)exp_uniq=combos*(1-(1-1/combos)**M)# 生日碰撞期望print(" 模板=%2d | 唯一句 %4d (碰撞理论 %4.0f, 组合上限 %5d) | 3-gram 熵 %5.2f bit | 最热 3-gram 占比 %.2f%%"%(n_tpl,uniq,exp_uniq,combos,ent,top*100))运行输出:
槽位组合上限 = 模板数 x 12 x 12 模板= 1 | 唯一句 144 (碰撞理论 144, 组合上限 144) | 3-gram 熵 6.10 bit | 最热 3-gram 占比 4.71% 模板= 2 | 唯一句 288 (碰撞理论 288, 组合上限 288) | 3-gram 熵 6.81 bit | 最热 3-gram 占比 3.26% 模板= 4 | 唯一句 574 (碰撞理论 573, 组合上限 576) | 3-gram 熵 7.80 bit | 最热 3-gram 占比 1.59% 模板=12 | 唯一句 1430 (碰撞理论 1424, 组合上限 1728) | 3-gram 熵 8.88 bit | 最热 3-gram 占比 1.33%模拟数字把教训量化了:单模板配 144 个槽位组合,写三千条只有 144 句不重复——其余全是碰撞复读;模板从 1 条加到 12 条,唯一句数几乎线性增长,3-gram 熵从 6.10 涨到 8.88。更微妙的是最热 3-gram 占比:模板少时任何三字片段都高度集中(模型会学到"报修"式口头禅),而 12 模板配置里残留的 1.33% 集中片段正是槽位高频词"设备"“处理”,属可接受。所以模板工程的检查动作是:生成前先算组合上限,生成后必测唯一率与 n-gram 熵;上限不够就加模板、加槽位维度(语气、客户角色、上下文片段),或者把模板产物交给强模型做同义改写再回流去重——用蒸馏的多样性补模板的骨架,两源合流的数据才既有分布又有变化。
常见陷阱
- 拍脑袋配比:不问工单系统就定"每类五千条",上线后高频事件排队、低频事件瞎答。配比参数永远来自线上统计,不来自直觉。
- 过采样当扩充:小类目重复倍数一路加到几十,train loss 漂亮、评测崩盘——模型背题了。上限+水位让渡是纪律。
- 蒸馏数据零改写:直接喂强模型原始输出,把它的结构偏好(列表癖、免责声明)烙进你的模型。蒸馏产出必须过 SOP 模板重写这一道。
- 负样本与红线缺失:只造"正确回答",模型没见过"该拒绝/该转人工"的样子。安全红线类目的答案本身就是行为规格,配比再小也不能为零。
- 评测集同分布幻觉:训练和评测都来自同一批模板,熵塌缩双方一致,分数全绿。评测集要掺真实工单原文与专家手写案例。
落地清单
- 每月从工单系统导出事件频率表,作为温度配比的输入;配比分配表随实验号归档
- 类目级重复上限默认 3~4 轮,水位算法自动暴露数据缺口清单
- 模板合成三件套:组合上限核算、唯一率报表、3-gram 熵基线(本篇实验即模板)
- 三层来源金字塔配比起步:蒸馏 60% / 改写 30% / 专家 10%,之后按评测反馈调
- 通用指令集以 5~10% 常驻配比防遗忘,理由见下篇实验
数据像业务了,下一个问题立刻浮现:改了这么多权重,模型原来的能力还在不在?下一篇《LLM 微调实战(6):评测集是护城河:微调效果的自动化评测》先建立打分基础设施——没有它,前面所有工程决策都是在裸奔。
参考来源
- Enhancing Chat Language Models by Scaling High-quality Instructional Conversations (UltraChat):https://arxiv.org/abs/2305.14233
- Fin-LLM: 金融研报问答领域的长文本响应生成微调:https://arxiv.org/abs/2109.01652
- FLAN: Scaling Instruction-Finetuned Language Models:https://arxiv.org/abs/2210.11416
- Wikipedia: Birthday problem(组合碰撞与唯一率公式):https://en.wikipedia.org/wiki/Birthday_problem
- PyTorch 文档: torch.utils.data.WeightedRandomSampler(配比采样的生产实现):https://pytorch.org/docs/stable/data.html