从数据反推模型架构:一个小模型训练的经验公式
引言:差了一个数量级
韵染流光是6060万参数,数语觅类是420万参数。同样是从零训练的小语言模型,参数量差了14倍。
我知道数语觅类更简单。韵染流光的DSL是我多次推翻重新设计的结果,自然语言理解、多轮上下文追踪、近似方法调用的DSL解析——这些东西叠在一起,学习难度很高。数语觅类就是给列名和样本数据,识别出语义类型,模式匹配就能解决大部分问题。
所以我一开始就刻意降了规模:512维,8层encoder,37.3M参数。比韵染流光小,但我觉得对这个任务来说够了。
第一个epoch,F1 99.7%。
我加了10倍数据,换了更大的架构:768维,12层,76.0M。两个epoch接近99.7%。
然后我开始往下砍。512维降到128维,8层降到2层,3.1M参数。第一个epoch,90%+。
这说明两件事:第一,我虽然知道数语觅类更简单,但我对“简单多少”的直觉差了一个数量级。第二,“上个项目的经验”是个危险的锚点——哪怕你已经主动校正了,校正的幅度可能还是不够。
这篇文章记录的就是从这个冲击出发,我怎么走到一个可以从数据反推模型维度的经验公式。它不是标准做法,适用范围也有限,但它至少让我在定参数时有了比拍脑门更好的起点。
两份语料,两种密度
韵染流光:模型要学会什么
先看最简单的情况。输入一个颜色词,输出对应的DSL:
红
color 628 258 29227
color 628 258 29227 是OKLCH颜色空间的三个分量——Lightness 0.628、Chroma 0.258、Hue 29.227——千倍取整后的结果。我当时觉得小数处理很复杂,就把精度外推给了解析程序:模型只管输出整数,外部程序除以1000还原。
这看起来不难,对吧?一个颜色词映射到三个数字。但韵染流光不只是单轮查表。
三轮对话的原始标注长这样:
红色 → color 628 258 29227
红色, 深一点 → color 628 258 29227 + lightness $p -0.1
红色, 深一点, 偏蓝绿 → 前序结果 + color 915 130 168990 + mix $p $p 0.1
但模型实际学习的不是这个。三轮对话会被拆成一个一阶段和两个三阶段——每个阶段都是独立的训练样本,输入包含完整的对话历史:
红色
color 628 258 29227
红色<sep>color 628 258 29227<sep>深一点
color 628 258 29227<sep>lightness $p -0.1
红色<sep>color 628 258 29227<sep>深一点<sep>color 628 258 29227<sep>lightness $p -0.1<sep>偏蓝绿
color 628 258 29227<sep>lightness $p -0.1<sep>color 915 130 168990<sep>mix $p $p 0.1
第三个样本的输入里,塞进了前两轮的全部历史。模型读到“偏蓝绿”的时候,需要理解这是对当前状态的修正——“当前状态”是前面所有轮次累积的结果。
再看一个更复杂的起始输入:
泛粉的暗纯热情红
color 774 165 12056
chroma $p 0.1
lightness $p -0.1
color 774 165 12056
mix $p $p 0.1
一句中文,五行DSL。我来拆一下模型需要从中学到什么:
中文语法结构——“泛粉的暗纯热情红”里,“泛粉的”、“暗纯”、“热情”分别修饰什么?“泛粉”是对整体的色调倾向描述,“暗纯”描述明度和饱和度状态,“热情”指向色相区域。模型要能拆开这些修饰关系。
DSL的结构与含义——color 是基色定义,chroma 调整饱和度,lightness 调整明度,mix 做颜色混合。$p 是对前序结果的引用。每条指令都是 command + parameters 的模式,但它们之间有执行顺序和依赖关系。
中文到DSL的映射——“暗”对应 lightness $p -0.1,“纯”对应 chroma $p 0.1,“泛粉”需要引入一个粉色基色再 mix。模型不只是在做翻译,它在把自然语言里隐含的操作序列还原出来。
上下文的结构与含义——多轮对话中,每轮输入包含之前所有轮次的完整历史。后续指令是对当前状态的修正,不是独立请求。模型需要理解“深一点”不是一个绝对描述,而是相对于上文的调整。
这些东西叠在一起,就是韵染流光的学习难度。
数语觅类:变成了什么
周岁|44Y<sep>82Y<sep>79Y<sep>45Y<sep>29Y
age|98 周岁<sep>87 周岁<sep>49 周岁
prop_4|年龄:74<sep>年龄:7<sep>年龄:85
field_5|age: 66<sep>age: 109<sep>age: 79
shuju1|2<sep>54<sep>105<sep>90<sep>3
列名加上几个数据样本,判断语义类型。上面五条全是“年龄”——不管列名叫“周岁”、“age”、“prop_4”还是“shuju1”,模型要从数据的模式里识别出来。
对比一下就很明显了。韵染流光要学中文语法、DSL结构、两者之间的映射关系、上下文累积机制——层层叠加。数语觅类要学的就是“这组数据长什么样”→“它是什么类型”,本质上是模式匹配。
信息密度:每个Token承载多少东西
这个差异没法精确量化,但方向很明确。
韵染流光的每个token都“贵”。color 774 165 12056 这一行里,color 承载了“这是基色定义”这个语义,774 165 12056 各自是OKLCH三个分量的精确值。DSL的语法规则、参数含义、指令间的依赖关系——这些信息分摊在每个token上。再加上多轮对话中历史上下文的累积,一个token可能同时参与“理解当前指令”和“维持上下文状态”两件事。
数语觅类的token就“便宜”得多。44Y<sep>82Y<sep>79Y<sep>45Y<sep>29Y 里的五个数据样本,本质上是同一个信息的重复确认——都在说“这列是年龄”。冗余度很高,几个样本看一眼就能判断,剩下的只是加强信心。
不同任务对参数的需求可以差出数量级,而“信息密度”是我能想到的最直观的解释。
特征提炼:模型需要思考几次
维度决定模型一次能处理多少信息,层数决定模型能思考几次。每过一层,就是一次特征提炼——把低层特征组合成更高层的抽象。
韵染流光的学习层次很多:先识别词汇,再理解语法结构,再建立中文到DSL的映射,再处理上下文依赖。每一层抽象都需要前一层的结果作为输入。这种逐层递进的特征提炼,直觉上需要更多层。
数语觅类就简单得多。识别数据模式,映射到语义类型。一两次特征提炼可能就够了——实际验证下来,1层encoder确实就能工作。
但我得诚实交代:这个“特征提炼次数”的理解是事后形成的。
韵染流光的层数选择是美学调参——encoder 8层、decoder 8层,加起来16层,2³+2³=2⁴,多整齐。选它不是因为“模型需要8次特征提炼”,而是因为它好看。
数语觅类的层数选择是暴力调参。一开始照搬韵染流光给了8层,发现严重过剩。降到2层,还是过剩。最后1层都多,靠把维度从768砍到128才让参数量合理。这不是“分析任务复杂度后决定层数”,这是“不停往下砍直到模型不再秒杀任务”。
3Blue1Brown在神经网络系列里说过类似的话——2层是因为数字识别可以拆成两次特征提炼,16个神经元……好看。
两个维度,两个输入
到这里,我手上有了两个值:
信息密度——语料中每个token承载多少需要学习的东西。它决定了token_per_param这个值:信息密度越高,每个参数需要的token越少(因为每个token提供了更多学习信号),token_per_param越小。这个值直接影响从总token数推出来的目标参数量。
特征提炼层次——模型需要多少次逐层抽象才能完成任务。它决定了层数,直接作为公式的输入。
有了这两个加上语料规模(总token数),就有了三个已知量。接下来的问题是:能不能从这三个已知量,反推出模型的维度?
拆零件:Transformer的参数构成
要从数据反推维度,得先搞清楚参数量是怎么来的——具体到每个组件贡献了多少,它们和d_model之间是什么关系。
Self-Attention:四个投影矩阵
注意力机制有四个线性变换:Query、Key、Value各一个投影,加上一个输出投影。每个都是 d_model × d_model 的矩阵:
- Q投影:d_model²
- K投影:d_model²
- V投影:d_model²
- 输出投影:d_model²
合计:4 × d_model²
多头注意力呢?如果有8个头,每个头的维度是 d_model / 8,Q投影变成8个 d_model × (d_model/8) 的矩阵——但拼起来还是 d_model × d_model。多头只是把同一块参数切成了几份并行处理,总参数量不变。
FFN:升维与降维
Feed-Forward Network是两层线性变换。输入 d_model 维,先升到一个更高的中间维度,再降回 d_model。中间维度默认是 d_model 的4倍,这个倍数叫 ffn_ratio。
- 升维:d_model × (ffn_ratio × d_model) = ffn_ratio × d_model²
- 降维:(ffn_ratio × d_model) × d_model = ffn_ratio × d_model²
合计:2 × ffn_ratio × d_model²
ffn_ratio = 4 时,就是 8 × d_model²。
为什么默认4倍?这是《Attention is All You Need》论文里的设定,后续工作基本沿用。不是什么理论最优,就是大家都用这个。如果某天想换成2倍或8倍,公式里改系数就行。
一个Encoder层的总参数
Self-Attention + FFN:
(4 + 2 × ffn_ratio) × d_model²
默认配置下:(4 + 2 × 4) × d_model² = 12 × d_model²
n个encoder层:12 × n_enc × d_model²
Decoder层:多了什么
标准encoder-decoder架构中,decoder层比encoder层多了一组cross-attention。Decoder需要“看一眼”encoder的输出来决定自己生成什么,这个“看一眼”就是cross-attention——Query来自decoder自己,Key和Value来自encoder的输出。
结构拆开来:
- Masked Self-Attention:4 × d_model²(和encoder的self-attention一样,只是加了mask防止看到未来的token)
- Cross-Attention:4 × d_model²(同样是Q/K/V/O四个投影矩阵)
- FFN:2 × ffn_ratio × d_model²
合计:(8 + 2 × ffn_ratio) × d_model²
默认配置下:(8 + 2 × 4) × d_model² = 16 × d_model²
比encoder多出来的那个4,就是cross-attention的四个投影矩阵。
Decoder-only:逻辑推论
GPT那一系的decoder-only架构,没有encoder,自然也没有cross-attention。每一层就是masked self-attention + FFN,参数构成和encoder层相同:
(4 + 2 × ffn_ratio) × d_model²
默认配置下也是 12 × d_model²。
但我得标注一下:这是逻辑推论。我做的两个项目一个是encoder-only,一个是encoder-decoder,decoder-only的参数分解我没有在实际项目中验证过。代码里也暂时没实现这条路径——等有了decoder-only的项目再说。
Embedding:几份权重?
Embedding层把token ID映射到d_model维的向量空间,形状是 vocab_size × d_model。Encoder-only架构只有一份embedding,这个没什么好说的。
Encoder-decoder架构就复杂一些了,因为多了一个东西:lm_head。
Decoder最后一层输出的是d_model维的隐藏向量,但我们需要的是“下一个token是词表中哪个词”的概率分布。lm_head就是做这件事的——一个 d_model → vocab_size 的线性投影,把隐藏状态映射回词表空间。
它的形状是 d_model × vocab_size,和embedding矩阵(vocab_size × d_model)刚好是转置关系。所以一个很自然的做法是直接复用embedding的权重,不额外占参数。
这就引出了三种共享策略:
全共享:encoder embedding、decoder embedding、lm_head三者共用一份权重。参数量:1 × vocab_size × d_model。三语翻译这种用同一个SentencePiece词表的场景,这是最自然的选择。
部分共享:encoder和decoder各有自己的embedding,但decoder embedding和lm_head共享。参数量:2 × vocab_size × d_model。encoder和decoder处理的语言差异大时可能需要这样。
全独立:三份权重各自独立。参数量:3 × vocab_size × d_model。实践中很少见——decoder embedding和lm_head共享几乎是默认做法,不共享反而需要理由。
忽略了什么
两样东西:每个线性层的bias(参数量级 O(d_model)),每个子层的LayerNorm(2 × d_model个参数,一组scale一组shift)。
拿个具体数字看看:d_model=256,6层encoder。主体参数(12 × 6 × 256²)约4.7M,bias加LayerNorm加起来大概20K。不到0.5%。
在我们要做的估算里——本身token_per_param就是个直觉判断——0.5%的误差完全淹没在直觉的不确定性里。忽略。
组装:从数据到方程
上一章把transformer拆成了零件,每个零件的参数量都可以用 d_model 表达。现在反过来——如果我知道总参数量应该是多少,能不能反推 d_model?
起点:token_per_param
先解决“总参数量应该是多少”这个问题。
我的出发点是 Chinchilla scaling law 的思路:模型参数量和训练数据量之间存在某种对应关系。Chinchilla 给出的经验值是每个参数大约对应20个token。但那是针对大模型、通用语料的结论,我的场景完全不同。
韵染流光,60.6M参数,训练语料约530万token,算下来 token_per_param ≈ 0.0875——每个参数不到0.1个token就学够了。数语觅类,4.2M参数,约1.07亿token,token_per_param ≈ 25.5。两者差了近300倍。
这个差距里混合了三个因素:架构不同(encoder-decoder vs encoder-only)、任务不同(生成 vs 分类)、信息密度不同(精心设计的DSL vs 简单的模式匹配)。我试过把它们拆开,拆不了。不知道架构本身贡献了多少差异,任务类型甚至没法精确定义,信息密度更是纯粹的直觉判断。
所以 token_per_param 就是一个综合值。它不是某个理论推导的结果,它是跑完实验之后的观测值。在新项目开始时,我填进去的那个数是基于经验的估计——看看语料长什么样,跟之前做过的项目比比,给一个直觉判断。
这样我就有了第一个等式:
它的价值不在于精确,在于给了一个比“上个项目用了多少”更好的起点。
参数方程
上一章拆出来的零件,装回去就是参数总量的组成。
对于 encoder-only 架构:
对于 encoder-decoder 架构:
其中 b 取决于 embedding 的共享策略——全共享是 vocab\_size,encoder 和 decoder 分开但 decoder 与 lm_head 共享是 2 \times vocab\_size,全独立是 3 \times vocab\_size。
两个等式联立——左边是 \frac{total\_token}{token\_per\_param},右边是关于 d_model 的多项式。整理一下:
其中:
- a = transformer 层的系数总和
- b = embedding 的系数
- P = \frac{total\_token}{token\_per\_param}(目标参数量)
一元二次方程。初中数学。
求解 d_model
求根公式:
这里有一个细节值得注意。标准求根公式里判别式是 b^2 - 4ac,而我们的 c = -P,所以 -4ac = -4a \times (-P) = 4aP,判别式变成了 b^2 + 4aP。
a 是层数乘以正系数,P 是目标参数量,b 是词表大小——全是正数。所以判别式必然为正,正根一定存在。不需要担心无解的情况。
求根公式会给出正负两个根,取正根。这个正根就是理论上的 d_model 值。
最后一步是对齐。GPU 在处理矩阵运算时,维度是 64(或 32)的倍数会更高效。所以把 d_model 向上取整到最近的 64 倍数。
汇总:三种架构的系数表
把 a 和 b 的取值整理成表。默认 ffn\_ratio = 4 时:
| 架构 | a(transformer系数) | b(embedding系数) |
|---|---|---|
| Encoder-only | 12 \times n_{enc} | vocab\_size |
| Encoder-decoder | 12 \times n_{enc} + 16 \times n_{dec} | vocab\_size \times (1 \text{ or } 2 \text{ or } 3) |
| Decoder-only(推论) | 12 \times n_{dec} | vocab\_size \times (1 \text{ or } 2) |
Decoder-only 的系数和 encoder 层相同——没有 cross-attention,只有 masked self-attention + FFN。这是逻辑推论,我还没有亲手做过 decoder-only 的项目,等有实际验证再说。
Embedding 系数的 “1 or 2 or 3” 取决于共享策略,具体在上一节已经拆过。Decoder-only 的 “1 or 2” 是 embedding 和 lm_head 是否共享。
统一的求解公式:
填入架构对应的 a 和 b,算出来,对齐到 64 的倍数,就是建议的模型维度。
落地:从公式到代码
公式有了,下一步是把它变成能直接调用的函数。
参数设计
先把函数签名定下来:
def d_model_calculator(
vocab_size: int,
total_token: int,
token_per_param: float,
n_encoder_layers: int,
n_decoder_layers: int = 0,
sharing_embedding: bool = False,
ffn_ratio: int = 4,
separate_lm_head: bool = False,
) -> int:
前四个没有默认值,因为它们描述的是“你有什么数据、想要什么架构”——每个项目都不一样,没有合理的默认值可以给。
后四个有默认值,按“你可能会动它的概率”排列:
n_decoder_layers:切换到encoder-decoder架构时必改,排最前sharing_embedding:架构级别的决策,和decoder紧密相关ffn_ratio:标准transformer就是4,几乎不动separate_lm_head:decoder embedding和lm_head不共享的情况极少见,排最后
参数排序这件事不影响功能,但影响使用体验。最可能调整的参数排在前面,调用时可以按位置传参而不用写关键字。
系数计算
函数内部要做的第一件事是确定二次方程的系数a和b。
encoder_layer_coef = 4 + 2 * ffn_ratio
decoder_layer_coef = 8 + 2 * ffn_ratio
encoder层是self-attention(4) + FFN(2 × ffn_ratio),decoder层多一组cross-attention(4)。默认ffn_ratio=4时,encoder层系数12,decoder层系数16。
然后根据架构类型组装:
if n_decoder_layers > 0:
a = encoder_layer_coef * n_encoder_layers + decoder_layer_coef * n_decoder_layers
b = vocab_size * ((1 if sharing_embedding else 2) + (1 if separate_lm_head else 0))
else:
a = encoder_layer_coef * n_encoder_layers
b = vocab_size
encoder-only架构下,embedding就是一份vocab_size × d_model,没有lm_head的问题。encoder-decoder架构下,embedding的份数取决于两个布尔值的组合:
| sharing_embedding | separate_lm_head | 倍数 | 含义 |
|---|---|---|---|
| True | False | 1 | encoder/decoder/lm_head三者共享 |
| True | True | 2 | encoder和decoder共享,lm_head独立 |
| False | False | 2 | encoder独立,decoder和lm_head共享 |
| False | True | 3 | 三者各自独立 |
(1 if sharing_embedding else 2) + (1 if separate_lm_head else 0) 恰好覆盖了1、2、3三种情况。
顺带处理一个边界情况:encoder-only架构下如果传了 separate_lm_head=True,给个提示然后忽略它,因为encoder-only没有lm_head这个东西。
求解与对齐
求根公式本身没什么好说的,上一章推过了:
c = -(total_token / token_per_param)
discriminant = b ** 2 - 4 * a * c
d_model_raw = (-b + math.sqrt(discriminant)) / (2 * a)
判别式 b² - 4ac = b² + 4aP,必然为正,正根一定存在。
有意思的是对齐这一步。GPU的矩阵运算对特定维度更友好——64的倍数是常见的对齐要求。但如果算出来的d_model太小,对齐就不是主要问题了:
if d_model_raw < 32:
raise ValueError("任务可能不适合transformer,考虑更简单的架构")
elif d_model_raw < 64:
print("警告:接近transformer下限,建议检查配置")
d_model = 64
else:
d_model = math.ceil(d_model_raw / 64) * 64
d_model算出来不到32,说明这个任务的复杂度可能根本不需要transformer——一个简单的全连接网络或者传统方法可能更合适。32到64之间是灰色地带,给个警告但不阻止。
完整代码
def d_model_calculator(
vocab_size: int,
total_token: int,
token_per_param: float,
n_encoder_layers: int,
n_decoder_layers: int = 0,
sharing_embedding: bool = False,
ffn_ratio: int = 4,
separate_lm_head: bool = False,
) -> int:
"""
依据层数反推对应维度
:param vocab_size: 词表大小
:param total_token: 总token数(样本数 * 样本平均token长度)
:param token_per_param: token参数比(5/10/20/50/100),每参数从多少token处学习;样本信息密度极高时,可能出现一个参数只需要不到1个token即可充分学习的情况——也就是说,一个token即可让多个参数学习
:param n_encoder_layers: 基于任务复杂度推断层数
:param n_decoder_layers: 基于任务复杂度推断层数
:param sharing_embedding: 编码和解码是否共享嵌入空间
:param ffn_ratio: FFN内部维度之于d_model的倍数
:param separate_lm_head: 输出投影层是否独立
:return: 对齐到64倍数的维度
"""
encoder_layer_coef = 4 + 2 * ffn_ratio
decoder_layer_coef = 8 + 2 * ffn_ratio
# 二次方程系数
if n_decoder_layers > 0:
# encoder-decoder架构
a = encoder_layer_coef * n_encoder_layers + decoder_layer_coef * n_decoder_layers
# 无论是否编解码是否共享权重,lm_head都可独立,需要单独计算
b = vocab_size * ((1 if sharing_embedding else 2) + (1 if separate_lm_head else 0))
else:
# encoder-only架构
if separate_lm_head:
print("encoder-only架构下不存在 lm_head 结构,参数 `separate_lm_head=True` 无作用")
a = encoder_layer_coef * n_encoder_layers
b = vocab_size
c = -(total_token / token_per_param)
# 求根公式
discriminant = b ** 2 - 4 * a * c
d_model_raw = (-b + math.sqrt(discriminant)) / (2 * a)
# 对齐到64倍
if d_model_raw < 32:
raise ValueError("任务可能不适合transformer,考虑更简单的架构")
elif d_model_raw < 64:
print("警告:接近transformer下限,建议检查配置")
d_model = 64
else:
d_model = math.ceil(d_model_raw / 64) * 64
return d_model
尾声:经验公式的边界
它解决了什么
数语觅类之前,我定参数的方式是“上个项目用了什么”。韵染流光512维8层,那数语觅类简单一些,降一点——512维8层,37.3M。结果差了一个数量级。
现在我有了一个方程。给定语料规模、token_per_param、层数,d_model可以算出来。不用从上个项目的配置开始校正,而是从这个项目的数据出发。
当然,方程里的token_per_param本身还是直觉判断。但“在一个公式里填一个需要估计的值”和“整体拍一个数”是不同的——前者至少让你知道你在估计什么,以及这个估计会怎么影响结果。token_per_param填大一点,d_model就小一点,关系是明确的。拍脑门没有这种可调性。
它没解决什么
token_per_param拆不开。它混合了架构、任务类型、信息密度三个因素,我试过把它们分离成独立系数,结论是做不到——韵染流光的0.0875和数语觅类的25.5之间差了接近300倍,我说不清多少来自架构差异,多少来自任务差异,多少来自语料本身。在新项目开跑之前,这个值只能靠经验估。
层数不在公式的射程范围内。公式接受层数作为输入,但层数本身怎么定?韵染流光是美学调参(2³+2³=2⁴,多整齐),数语觅类是暴力调参(8层太多?砍!2层还多?!1层!),现在我会说“看任务需要几次特征提炼”——听起来有道理了,但本质上还是拍。只是从没有依据的拍,变成了有一套说辞的拍——听起来像那么回事,但拍的本质没变。我管这叫“爹味调参”。