DDOMAIN·LAB

领域分布小模型 v3 · 原理

它由什么组成、训练时到底在学什么、为什么有些错是注定会犯的。

这份文档讲三件事:模型由什么组成、训练时到底在学什么、为什么有些错是注定会犯的。想知道它有多准、能拿来干什么,看模型卡。

一个 33,353,760 参数(3,335 万)的 decoder-only 语言模型,从随机初始化开始,在 3.56 亿字中文行业语料上训练。它和普通语言模型的区别只有一个:读完最后一个字时,从同一份隐状态里再吐出一个 31 维的领域分布。

它只会两件事——猜下一个字,和给整段文字判行业。第二件是第一件的副产品。

结构

实线框是推理时真正跑的部分;虚线框是训练时才用到的那一路。两个出口共用同一个 512 维隐状态,没有各训各的。点图可以放大。
模型架构 · 8 层 · d=512 · 两个出口
形态decoder-only Transformer,手写 PyTorch,不依赖 transformers
层数 / 宽度8 层 · d=512 · 8 头 · head_dim 64 · FFN 2048
位置编码RoPE,不加参数
归一化Pre-LN,每个子层前做 LayerNorm,输出前再过一次 ln_f
上下文512 字,字符级
词表15,341
Dropout0.1
两个出口语言模型头(15,341 维)+ 领域头(31 维)

参数都花在哪

字表嵌入 15341×5127,854,592 · 23.5%
8 个 Transformer 块25,219,072 · 75.6%
输出层归一化1,024
领域头 512→512→31278,559 · 0.83%
池化打分(仅 attn 池化用)513
语言模型头0 · 与字表嵌入共享权重
合计33,353,760

单个块拆开:多头自注意力 787,968(qkv)+ 262,656(proj)+ 2×1,024(LN),前馈 1,050,624 + 1,049,088。 存档 best.pt 是 382 MB,因为带 AdamW 的一阶、二阶矩;纯权重 fp32 约 127 MB。

这张表最重要的信息是最后两行:语言模型头一个参数都不占,领域头只占 0.83%。 整个模型的容量几乎全在「怎么读文本」上,而不是「怎么分类」。

词表与输入

15,341 个词条:10,305 个汉字单字、5,031 个字母数字与符号、5 个控制符。 整个词表里只有一个中文标点:·,没有 , 。 、。 这不是设计选择,是语料决定的——IndustryCorpus2 的正文里逗号句号一律是半角。

后果:直接把真实文本喂进去,每个「,」都会查不到、变成 <unk>。 而它在训练数据里出现 0 次,向量是随机初始化的。等于每十个字插一个随机噪声,句子边界也全断了。 所以推理前必须归一化(src/textproc.py):全角转半角,字表外的字直接丢弃。

还有一条:短于 512 字要重复补齐。训练时模型永远见 512 字,短输入属于没见过的形态。 实测同一批文档,直接判前 30 字命中 29.2%,重复补齐到 512 后 62.5%——信息一个字没多,只是长度对了。

训练数据

语料来自智源 IndustryCorpus2(Apache-2.0),按行业分了子集,文件夹名本身就是标签,一条标注都不用写。

规模316,712 篇 · 356,481,811 字
切分训练 285,056 篇 / 验证 31,656 篇(按文档切)
类别30 类有样本;第 31 个「其他」槽永远没有正样本

训练配置

损失猜字交叉熵 + 2.0 × 领域交叉熵(平滑 0.95)+ 0.5 × 两块一致性 + 0.1 × 对比
优化器AdamW,β=(0.9, 0.95),weight decay 0.1,梯度裁剪 1.0
学习率3e-4,余弦衰减,warmup 800 步
批与长度batch 8 · 块长 512,训练中在 256~512 随机切换
训练量129,600 步(最佳快照 129,000 步)
硬件单卡 RTX 2080 SUPER 8G,fp16,显存 2.99 GB,约 6 小时

计划 15 万步,在 12.96 万步手动停止。学习率已降到 1.4e-5,接近收尾,但不是严格跑满的退火。

两个出口为什么是这么接的

池化取最后一个位置,这是 decoder-only 的必然结果:第 100 个字只能看见第 1 到 100 个字, 看不见后面。前面所有位置都是「半瞎」的,只有最后那个看完全文。取平均会把一堆只看过半句的状态混进来。

代价是整篇文档必须挤进一个 512 维向量——信息瓶颈在这儿,不在分类头。

训练其实是四项损失

src/model.py 里只有两项(语言 + 领域),另外两项在 src/train.py 里算:

总损失 = 语言损失 + 2.0 × 领域损失 + 0.5 × 一致性损失 + 0.1 × 对比损失
语言损失看到前 500 字,第 501 个字该是什么 · 不需要标签,下一个字就是答案
领域损失给真类分了多少概率 · 需要标签
一致性损失同一篇文档的两个片段,输出分布互相靠拢(对称 KL)· 不需要标签
对比损失同篇两段在向量空间靠近、batch 内其他文档推开(InfoNCE,温度 0.07)· 不需要标签

后两项连标签都不需要,只要求「同一篇文档的不同位置应该给出同一个答案」。这是自监督——模型自己跟自己较劲。 因为一篇文档要取两个片段各跑一次前向,每步实际是 2 次前向、1 次反向。

损失衡量的是概率,不是对错

标签不是 one-hot,是平滑过的(smooth=0.05):真类 0.95,其余 30 类各 0.00167。于是:

判对了但只有 51% 把握 → 还是被扣分,离 0.95 差得远;判错了但真类还留着 40% → 扣得不多。 它惩罚的是「你有多确信地错」,不是「你错没错」。

为什么不直接用对错?「对错」是阶跃函数,导数处处为 0(跳变点还不可导),梯度传不回去,反向传播整个断掉。 交叉熵是可导的替身:把「对错」换成「概率离目标差多远」。代价是它只管离目标多远,不管类别对不对。

因为标签是软的,领域损失永远降不到 0。理论下限是 0.95×ln(1/0.95) + 30×0.00167×ln(1/0.00167) ≈ 0.37。 训练日志里它停在 0.3 左右——已经贴着下限。剩下 15% 的错不是「没练够」,是目标函数测不出更多区别。

没人对它说「你错了」,它怎么学会分类

说了,只是不用语言说。文件夹名就是答案。石油化工目录里的文档,训练时被塞进「第 17 格 0.95 分」这个目标。 区别只在于,「对不对」不是一句话,而是一列数。把 31 类压成 3 类看:

石油化工模型给 0.337 · 目标 0.95 · 差 −0.613 · 给低了,往上抬
电力能源模型给 0.413 · 目标 0.025 · 差 +0.388 · 给高了,往下压
计算机编程模型给 0.250 · 目标 0.025 · 差 +0.225 · 给高了,往下压

最右两列就是「你猜得对不对」的连续版本。数学上关系非常干净: softmax + 交叉熵的梯度,恰好等于「预测概率 − 目标概率」。 反向传播不需要知道「对错」这个词,它拿到的是一个 31 维向量,里面写着哪格该抬、抬多少。

这个向量乘上 512 维的输入,得到一个 31×512 的修正矩阵,加上去;然后往回流,过 GELU、第一层、8 层,每层改一点点。

为什么学出来的是分类,不是背题

两件事挡着:每 batch 8 篇,梯度取平均,模型不能只为这一篇改,必须找一个对 8 篇都成立的方向; 每次只走一小步,学习率限制改动幅度。十二万九千步,每步一点点。

收敛路径因此是「慢慢逼近一个对所有样本都还行的位置」,不是「记住每一篇」。

31 个格子怎么对上号

领域头是两层,不是一层:512 → 512 → GELU → 31 → softmax。 最后那层是 31 条点积向量,一个类别一条。打分就是量「这份文档跟这条向量有多像」。

压成 3 维举例,文档向量 [0.8, −0.3, 0.5]:石油化工的权重 [2.0, −0.5, 1.5] 点积得 2.5; 体育的权重 [−1.0, 0.2, −0.8] 点积得 −1.26。方向对上的得分高,反着的减分。

第一层的作用是「重新组合」:原本散在几十个维度里的证据收敛成几个更明确的中间特征;GELU 把负数压成 0, 等于只让有证据的信号往下走。

训练在干的事:31 条向量初始化时是空间里随机指的 31 个方向,谁也不认识谁。每来一篇文档, 就把对应那条转向这个向量,把其他条转开。一万篇之后,每条都转到「自己那类样本扎堆的方向」上。

它没有学「石油化工是什么」,它学的是「被标成第 17 格的那堆样本,向量有什么共同点」。

这个分类头实测学成了什么样

翻 best.pt 里的实际权重:

第一层 512→51226.3 万参数 · std 0.020 → 0.029 · 几乎没动
第二层 512→311.6 万参数 · std 0.020 → 0.303 · 涨了 15 倍
第二层 bias31 个 · 均值 −0.225 · 整体往负的挪
合计278,559 参数 · 占全模型 0.83%

第一层基本没学到结构,12.9 万步下来还接近初始随机值。活全在第二层那 31 条点积上。 bias 往负的挪,是因为标签是软标签,模型学会了「别把话说满」。

这意味着这个头的表达能力有一大半是浪费的。可以试三种改法,每个几行代码:删掉第一层(512→31 直达)、 给它加个 LayerNorm、或者换成中间更窄的 512→128→31。这三个消融还没做。

认领域这件事,99% 的功劳在前面 8 层。头只是把 512 维里已经存在的区分读出来—— 如果那 512 维里压根分不开两类,这 28 万参数怎么调都没用。

推理时逐层发生了什么

拿「催化裂化装置开工前需要检查」走一遍:

第 0 层字变向量。「催」和「化」的向量毫无关系,纯随机初始化
第 1–2 层学局部搭配。「催化」后面跟「裂化」概率高,注意力把这两个字连起来
第 3–5 层学短语级概念,信息混进同一个方向
第 6–8 层把整段语境压进最后一个位置的 512 维向量
领域头读这个向量,石油化工那格得分最高

注意力里 QK 决定「看谁」,V 决定「搬什么」,两者都是学出来的,不是人定的。 全程没有任何「关键词 → 行业」的对照表,全是矩阵乘法。

它大概率就是在抓关键词(未验证)

一篇写城市一天的散文里出现了菜市场、粮油店、超市货架、奶茶、面馆、烤串、薯片,它判餐饮 93%。 如果真懂了语义,应该知道这是在写城市生活,餐饮只是素材。但它看到足够多的餐饮词就投票了。

同理,测到的「新闻传媒 → 消防安全」大概是抓到了「通报」「检查」「整改」; 「数学 → 住宿餐饮」可能是因为「样本」「抽样」在餐饮食材语料里也高频。

如果假说成立,85% 的准确率其实来自「行业语料的用词太集中」——判石油化工根本不需要理解催化裂化。 剩下的错,全出现在「没有强关键词」或「关键词跨类」的文档上。

验证方法:取判餐饮的那篇散文,看 8 层注意力的权重落在哪几个字上。落在「面馆」「奶茶」上,假说成立; 散在全篇,说明它在做更全局的事。这个实验还没做。

三处实测出来的边界

长度

同一批验证文档,只截前 N 字(每类 3 篇共 90 篇):

30 / 80 / 150 / 300 / 512 字44.4% · 51.1% · 56.7% · 72.2% · 82.2%
600 篇类均衡85.2%
2000 篇自然分布88.45%(大类更多,而大类恰好更好分)

重复补齐的效果:前 30 字直接判 29.2% / 置信 0.309,补齐到 512 后 62.5% / 0.735; 前 80 字 46.7% / 0.391,补齐后 75.8% / 0.792。 30 字基本是瞎猜;补齐后大幅提升,但置信度也被一起抬高了——它并没有更懂。

全角标点

512 字 · 240 篇85.0% / 置信 0.776 → 86.7% / 0.820
40 字 · 240 篇63.3% / 0.721 → 67.5% / 0.755
DeepSeek 报道 · 150 篇64.0% / 0.626 → 62.0% / 0.685(噪声)

准确率变化在噪声边缘,置信度三组全升 0.03~0.06。标点被吃掉的代价是「模型变得没底」, 不是「判错」。它还能从上下文猜对,但概率被噪声摊平了——这对「把概率当权重用」影响很大,对「取最大值」影响很小。

一个还没解释的副作用:手写 30 字短句改标点后从 30% 跳到 76%,远大于 40 字切片的 +4.2pt。 怀疑是重复补齐把 <unk> 变成了周期性信号,未验证。

类目

600 篇均衡评测:航空航天、计算机编程_代码 100%;数学、消防安全、新闻传媒、信息安全 95%。 最差的是计算机_通信 50%、其他制造 60%、科技_科学研究 60%、石油化工 60%、电力能源 65%。

这几个的边界本身就重叠。不是分类头不会分,是那几堆向量本来就没分开。

标签是天花板,还有一格是空的

类目表有 31 格,但第 31 格「其他」一个训练样本都没有。数据全是按行业分好的文件夹, 没有「什么都沾一点」的文档喂给它。所以它学不会输出这一格,只能从 30 个真类里硬选一个,选了还很自信。

那篇城市散文本该落在这里——它同时沾金融、餐饮、文学,正确做法是「不确定」。但模型只能给出 93% 的餐饮。 要修的不是模型,是数据。补 300–500 篇散文、随笔、城市观察、混合话题的帖子, 这一格才会激活——槽位一直存在,只是从没被填过。

顺带一个观察:有些错误不是模型判错,是标签本身在打架。 「计算机_通信 / 计算机编程 / 科技_科学研究」这三类,人自己也分不清边界在哪。

一句话总结:模型不是「理解」了行业,它复现的是人类的分类习惯。人分得清的它学得会; 人自己都含糊的它学成一团;人没给过例子的它永远不会输出。

还没搞清的三件事

短句补齐是否放大了标点噪声——手写 30 字短句改标点后 30% → 76%,远大于 40 字切片的 +4.2pt
分布外掉点是「文风不对」还是「题目本身模糊」——同类语料 85%,DeepSeek 写的 150 篇只有 64%
天花板是容量不够还是标签重叠——分类头几乎没在动这件事倾向后者,但没做消融

复现

$PY = C:/Users/hcy/anaconda3/envs/vibe_mnist/python.exe

# 单段文字的完整分布(含原样 / 归一化对比)
& $PY eval/one_text.py --file H:/llm-domain-lab/user_sample.txt --top 8

# 全角/半角控制变量
& $PY eval/eval_punct.py --source valid --docs 240
& $PY eval/eval_punct.py --source valid --docs 240 --trunc 40
& $PY eval/eval_punct.py --source llm --file H:/llm-domain-lab/llm_news_150.jsonl

# 每类命中率
& $PY eval/eval_per_class.py