引言:差了一个数量级

韵染流光是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 就是一个综合值。它不是某个理论推导的结果,它是跑完实验之后的观测值。在新项目开始时,我填进去的那个数是基于经验的估计——看看语料长什么样,跟之前做过的项目比比,给一个直觉判断。

这样我就有了第一个等式:

total\_param = \frac{total\_token}{token\_per\_param}

它的价值不在于精确,在于给了一个比“上个项目用了多少”更好的起点。

参数方程

上一章拆出来的零件,装回去就是参数总量的组成。

对于 encoder-only 架构:

total\_param = vocab\_size \times d\_model + (4 + 2 \times ffn\_ratio) \times n_{enc} \times d\_model^2

对于 encoder-decoder 架构:

total\_param = b \times d\_model + \left[(4 + 2 \times ffn\_ratio) \times n_{enc} + (8 + 2 \times ffn\_ratio) \times n_{dec}\right] \times d\_model^2

其中 ​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 \times d\_model^2 + b \times d\_model - P = 0

其中:

  • ​a = transformer 层的系数总和
  • ​b = embedding 的系数
  • ​P = \frac{total\_token}{token\_per\_param}(目标参数量)

一元二次方程。初中数学。

求解 d_model

求根公式:

d\_model = \frac{-b + \sqrt{b^2 + 4aP}}{2a}

这里有一个细节值得注意。标准求根公式里判别式是 ​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 是否共享。

统一的求解公式:

d\_model = \frac{-b + \sqrt{b^2 + 4aP}}{2a}, \quad P = \frac{total\_token}{token\_per\_param}

填入架构对应的 ​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层!),现在我会说“看任务需要几次特征提炼”——听起来有道理了,但本质上还是拍。只是从没有依据的拍,变成了有一套说辞的拍——听起来像那么回事,但拍的本质没变。我管这叫“爹味调参”。