领域分布小模型 v3 · 原理
- 结构
- 训练机制
- 实测权重
- 边界
它由什么组成、训练时到底在学什么、为什么有些错是注定会犯的。
这份文档讲三件事:模型由什么组成、训练时到底在学什么、为什么有些错是注定会犯的。想知道它有多准、能拿来干什么,看模型卡。
一个 33,353,760 参数(3,335 万)的 decoder-only 语言模型,从随机初始化开始,在 3.56 亿字中文行业语料上训练。它和普通语言模型的区别只有一个:读完最后一个字时,从同一份隐状态里再吐出一个 31 维的领域分布。
它只会两件事——猜下一个字,和给整段文字判行业。第二件是第一件的副产品。
结构
| 形态 | decoder-only Transformer,手写 PyTorch,不依赖 transformers |
|---|---|
| 层数 / 宽度 | 8 层 · d=512 · 8 头 · head_dim 64 · FFN 2048 |
| 位置编码 | RoPE,不加参数 |
| 归一化 | Pre-LN,每个子层前做 LayerNorm,输出前再过一次 ln_f |
| 上下文 | 512 字,字符级 |
| 词表 | 15,341 |
| Dropout | 0.1 |
| 两个出口 | 语言模型头(15,341 维)+ 领域头(31 维) |
参数都花在哪
| 字表嵌入 15341×512 | 7,854,592 · 23.5% |
|---|---|
| 8 个 Transformer 块 | 25,219,072 · 75.6% |
| 输出层归一化 | 1,024 |
| 领域头 512→512→31 | 278,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。
词表与输入
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(跳变点还不可导),梯度传不回去,反向传播整个断掉。 交叉熵是可导的替身:把「对错」换成「概率离目标差多远」。代价是它只管离目标多远,不管类别对不对。
没人对它说「你错了」,它怎么学会分类
说了,只是不用语言说。文件夹名就是答案。石油化工目录里的文档,训练时被塞进「第 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 个方向,谁也不认识谁。每来一篇文档, 就把对应那条转向这个向量,把其他条转开。一万篇之后,每条都转到「自己那类样本扎堆的方向」上。
这个分类头实测学成了什么样
翻 best.pt 里的实际权重:
| 第一层 512→512 | 26.3 万参数 · std 0.020 → 0.029 · 几乎没动 |
|---|---|
| 第二层 512→31 | 1.6 万参数 · std 0.020 → 0.303 · 涨了 15 倍 |
| 第二层 bias | 31 个 · 均值 −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% 的准确率其实来自「行业语料的用词太集中」——判石油化工根本不需要理解催化裂化。 剩下的错,全出现在「没有强关键词」或「关键词跨类」的文档上。
三处实测出来的边界
长度
同一批验证文档,只截前 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