2500万参数字节级 Transformer:CPU 训练的结构与评估
用约 2500 万参数的教学模型,可以把编码、因果注意力、损失、反向传播和评估连成一条可检查的链路。本文从一组明确的配置出发,讨论参数放在哪里、字节输入增加什么代价,以及什么时候值得加入注意力变体、MoE 或强化学习。先建立小规模基线,再比较每次结构变化。
先定义实验,不用模型名称替代配置
这里的 25M 是本文设计的教学预算。结构采用 Llama 风格的因果语言模型,词表、层数、宽度与权重共享都显式给出。模型从随机参数开始,而不是从一个名称相似的预训练目录加载权重。
一组可以逐项计数的配置
输入词表为 256,隐藏维度为 512,7 层,8 个注意力头,FFN 中间维度为 1632。使用普通多头注意力、无偏置投影与 SwiGLU,输入嵌入和输出投影共享权重。主要矩阵与归一化参数合计约为 25,026,048 个,构建后再用实际参数计数核对。
from transformers import LlamaConfig, LlamaForCausalLM
config = LlamaConfig(
vocab_size=256, hidden_size=512, intermediate_size=1632,
num_hidden_layers=7, num_attention_heads=8, num_key_value_heads=8,
max_position_embeddings=256, tie_word_embeddings=True,
bos_token_id=None, eos_token_id=None, pad_token_id=None,
attention_bias=False, mlp_bias=False,
)
model = LlamaForCausalLM(config).to("cpu")
print(sum(p.numel() for p in model.parameters()))
配置字段与构建方式可对照 Transformers 的 Llama 文档。CPU 单步耗时还取决于线程数、序列长度、batch 和所用算子;先测一次前向与反向,再安排训练预算,不预设“几分钟训练完成”。
把一次实验写成一张记录
| 项目 | 要保存的内容 | 用途 |
|---|---|---|
| 数据 | 来源、许可证、清洗规则与划分 | 避免训练评估泄漏 |
| 配置 | 层数、宽度、头数、词表与共享开关 | 解释参数量变化 |
| 运行 | CPU、线程、batch、序列长度与种子 | 比较单步成本 |
| 指标 | 训练损失、验证损失、目标任务结果 | 判断是否有收益 |
小模型可以降低一次结构检查的资源需求,但不会消除实验设计问题。某种配方在此处有效,放大到另一种模型和数据上仍要重新验证。
字节输入:256 个基本 ID 的收益与代价
把文本编码为 UTF-8,再直接使用每个字节的数值作为 token ID,就得到一个无需学习合并规则的输入方法。字节值为 0 到 255;添加结束、填充或其他控制符号时,应另分配 ID 并扩大词表。
text = "你好,模型"
ids = list(text.encode("utf-8"))
assert all(0 <= value < 256 for value in ids)
assert bytes(ids).decode("utf-8") == text
print(len(text), len(ids))
这个往返适用于可编码为 UTF-8 的文本。任意二进制序列未必能解码为有效文本,生成阶段需要约定错误处理方式。常见汉字通常占三个 UTF-8 字节,其他字符可能不同;不要把“字符数”和“模型序列长度”混用。
子词分词并不必然存在未知词
BPE、WordPiece 与直接字节编码解决问题的方式不同。具体 tokenizer 是否使用 unknown、字节回退或字节级基础符号,应查看其实现。Byte-level BPE 同样可以保留字节覆盖,不能把所有 BPE 都概括为“遇到新词只能输出 unknown”。
| 方案 | 基本规则 | 要比较的代价 |
|---|---|---|
| 子词分词 | 常见片段可以合并 | 词表规模、序列长度与回退机制 |
| 直接字节 ID | 每个 UTF-8 字节直接成为 ID | 小词表与更长文本序列 |
| 字节加控制符号 | 字节外另加特殊 ID | 数据边界与生成终止规则 |
同一份数据应实际编码后再比较。不能先假定一句中文在某个 BPE 词表里一定只有多少 token,再据此计算固定倍数的收益。
Embedding 只做查表,后面仍有输出计算
import torch
ids = list("你好,模型".encode("utf-8"))
input_ids = torch.tensor([ids], dtype=torch.long)
embedding = torch.nn.Embedding(256, 512)
vectors = embedding(input_ids)
assert vectors.shape == (1, len(ids), 512)
嵌入矩阵的参数量是 V×d。V 为词表大小,d 为隐藏维度;独立输出投影通常也需要同量级权重。共享这两份权重减少独立参数,同时约束输入和输出表示,不能说它完全不影响模型约束。
字节输入的序列可能更长。密集注意力的交互成本与长度平方相关,KV cache 则还取决于层数、KV 头数、head_dim 和存储精度。省下的词表参数与增加的序列成本,要放进同一份预算里。
按矩阵形状拆开 25M 参数
固定隐藏维度 d,普通多头注意力的 Q、K、V、O 无偏置投影约为 4d²;SwiGLU 的三个主要矩阵约为 3d×d_ff。GQA 等结构会改变 K/V 的尺寸,不能直接沿用这个公式。
本文配置的主要参数可以用普通算术核对:
vocab, hidden, intermediate, layers = 256, 512, 1632, 7
embedding = vocab * hidden
per_layer = 4 * hidden**2 + 3 * hidden * intermediate + 2 * hidden
parameters = embedding + layers * per_layer + hidden
assert parameters == 25_026_048
print(parameters)
其中每层两个 RMSNorm 各含 d 个缩放参数,最终归一化再加 d;输出复用嵌入,因此没有另算一份输出矩阵。这个算式对应前面的具体选择,不是所有“25M 模型”的统一规格。
前向通路怎样检查
输入形状为 [B,T],查表后为 [B,T,d],经过因果注意力和 FFN,再归一化并投影到 [B,T,V]。pre-norm 路线把归一化放在子层输入端,残差保留原状态;这是本文选择的结构。
验证顺序是先检查形状,再检查未来信息是否被因果掩码挡住,随后检查 loss、梯度和保存加载。最后一个位置不能通过标签错位读取自己要预测的答案。使用因果语言模型接口时,应明确库内部如何移动 labels,避免在外部又移动一次。
权重绑定只省独立参数,不省去输出矩阵乘。输入查表取少数行,输出仍要对词表计算分数;输入和输出两侧的梯度还会汇入同一参数。
注意力比较:位置、连接与状态是三件事
RoPE 给 Query 与 Key 施加随位置变化的旋转,使相对位置信息进入内积。RoFormer 论文解释了这一构造。相对位移形式不保证任意长上下文上的质量,扩展长度仍要核对缩放方法和实际评测。
一个明确形状的旋转示例
import torch
def apply_rope(x, positions, base=10000.0):
# x:[B,H,T,D],positions:[B,T],采用前后半维配对。
dimension = x.shape[-1]
if dimension % 2:
raise ValueError("最后一维须为偶数")
half = dimension // 2
exponent = torch.arange(half, device=x.device).float() / half
angle = positions.to(x.device).float()[:, None, :, None] * base**(-exponent)
cosine, sine = angle.cos().to(x.dtype), angle.sin().to(x.dtype)
first, second = x[..., :half], x[..., half:]
return torch.cat((first*cosine-second*sine, second*cosine+first*sine), -1)
旋转配对和维度排列必须与模型实现一致。换一种排列后,不能仅因输出形状相同就认为权重兼容。NoPE 省去显式位置编码,但因果掩码等结构仍能影响位置信息。
稀疏索引与压缩 KV 不同
稀疏注意力选择部分位置参与交互;索引、排序和稀疏算子都有成本。MLA 压缩 KV 表示,也不等于已经加入稀疏位置选择。DeepSeek-V3.2-Exp明确介绍了 DeepSeek Sparse Attention;不能把 V2/V3 的 MLA 统称为同一种稀疏索引方案。
线性注意力可以通过特征映射与累积状态重新组织计算,避免构造完整的 N×N 交互矩阵。固定状态维度时,可以按序列步数递推;状态空间模型是另一类结构,不能与所有线性注意力实现画等号。
| 路线 | 主要代价 | 评估重点 |
|---|---|---|
| 密集注意力 | 序列两两交互、KV 存储 | 因果正确性与长序列成本 |
| 稀疏注意力 | 候选选择和稀疏算子 | 保留连接是否覆盖任务 |
| 递推状态 | 状态更新与数值稳定 | 长序列记忆与检索质量 |
比较时固定数据、参数预算与序列长度;同时记录实际速度和质量,不用复杂度符号直接宣布某一种方案更好。
MoE 与视觉扩展:增加结构就增加检查项
MoE 用路由器选择部分专家处理一个 token。总参数量与激活参数量不同:即使只调用部分专家,训练或推理的驻留权重仍可能包含全部专家,梯度、优化器状态和通信也占资源。
下面只展示一个 token 的 top-k 路由接口,router 与每个专家都由调用方传入:
import torch
def route_one_token(x, router, experts, k=2):
if not 1 <= k <= len(experts):
raise ValueError("k超出专家数量")
scores, indices = torch.topk(router(x), k)
weights = torch.softmax(scores, dim=-1)
outputs = torch.stack([experts[int(i)](x) for i in indices])
return (weights[:, None] * outputs).sum(dim=0)
实际训练还要处理批量路由、负载分布、容量和并行通信。DeepSeek-V2 论文讨论了共享与路由专家;共享专家始终参与是一种结构安排,不能直接推断任意小模型都会更容易收敛。保留同预算 dense 基线,再看收益与额外成本。
视觉输入从 patch 数开始
把图像切成 patch 后,每个 patch 投影到模型维度。对可整除的 H×W 图像,16×16 patch 的基本数量为 (H/16)×(W/16);额外 token 和预处理方式由具体实现决定。
二维位置编码表示横纵坐标。采用二维 RoPE 时,需要约定旋转维度怎样分给不同轴,并非所有实现都采用“偶数维横向、奇数维纵向”。改变分辨率会改变长度和数据分布,应重新验证投影、索引与目标任务。
从数据到一次训练更新
先划分训练集和验证集,再清洗、编码、打包和抽样。按文档或任务分组划分,可以减少相同内容的近重复片段落入两边。拼接样本时,应明确跨文档边界是否允许注意力连接。
优化器的名字不能替代实验
AdamW 把权重衰减与自适应梯度更新解耦,具体系数和参数分组仍须选择。PyTorch AdamW 文档列出了算法与配置。下例给出单步接口,调用方提供已经构建的模型、整数 ID 和优化器:
def train_step(model, input_ids, optimizer):
model.train()
optimizer.zero_grad(set_to_none=True)
loss = model(input_ids=input_ids, labels=input_ids).loss
loss.backward()
optimizer.step()
return float(loss.detach())
这里使用等长、未填充的文本块。增加 padding 后,还要设置 attention mask 并屏蔽填充标签。保存模型配置、数据版本、有效 token 数、训练步数和耗时,才能解释验证损失的变化。
数据顺序是变量,不是固定配方
交错采样按比例混合来源,课程学习随阶段改变来源或难度,两者都应在同一预算下比较。课程学习可能产生顺序效应,混合比例也可能偏离目标任务;没有一个表格能预先保证哪条路线 loss 最低。
对不同来源分别建立验证集。总体 loss 下降时,某一类任务仍可能退化;只看一个平均值会掩盖这个变化。放大实验前,先确认数据边界、采样权重和对照设置都可以复现。
后训练:先定义奖励与评估预算
监督微调学习示范序列;采用可验证奖励的强化学习则根据任务结果优化策略。奖励可以来自测试、规则或评审模型,但每种验证器都有成本和覆盖边界。代码执行尤其需要隔离环境与资源限制。
GRPO 的组内相对信号
DeepSeekMath 论文提出 GRPO,用同一提示下的一组候选奖励构造相对信号,减少对独立价值模型的依赖。具体实现的标准化、KL 项与采样设置应查看训练器配置;下面仅演示一种组内标准化:
def group_advantages(rewards, group_size, eps=1e-4):
if group_size < 2 or rewards.ndim != 1 or rewards.numel() % group_size:
raise ValueError("奖励数量须能分成至少两项的小组")
grouped = rewards.view(-1, group_size)
mean = grouped.mean(dim=-1, keepdim=True)
std = grouped.std(dim=-1, keepdim=True, correction=0)
return ((grouped-mean)/(std+eps)).flatten()
一组奖励完全相同时,相对信号可能为零;奖励解析错误、测试缺口或超时处理不当,也会扭曲训练方向。先人工检查若干真实样本,再启动批量训练。参数高效更新只改变可训练参数的范围,不免除奖励、采样和执行成本。
Pass@k 与验证损失一起看
Pass@k 描述多次生成中至少一个候选通过验证的情况;需要同时报告 k、采样方式与验证规则。验证损失衡量保留文本分布上的预测表现,与特定任务通过率不是同一指标。
任务通过率提高时,还要检查通用任务、异常输出和验证器漏洞。训练奖励升高,可能来自完成任务,也可能来自利用规则缺口;应保留未公开的评估任务,并抽查实际行为。
from statistics import mean, variance
def summarize(runs):
if len(runs) < 2:
raise ValueError("至少两次运行才能计算样本方差")
return {name: {"mean": mean(r[name] for r in runs),
"variance": variance(r[name] for r in runs)}
for name in ("pass_at_k", "heldout_loss")}
runs 应来自真实重复实验,每次只改变约定的种子或变量,保留配置与预算。先把编码往返、参数计数、因果性、单步梯度与保存加载做实,再加入复杂注意力、专家路由和后训练。每增加一种结构,就用同预算基线检查它是否解决了原问题。