python神经网络编程入门(三十八)——从零实现 Transformer 前向传播

📍 路标 :本篇位于《从零构建 Transformer》系列 第 9/16 章 · 架构篇

系列主线:注意力思想 → 自注意力 → 多头 → 位置编码 → 编解码架构 → 手撕实现 → 预训练模型 → 实战微调 → 知识收官

进度:▸ 基石篇(1-5) ▸ 架构篇(6-10·本篇) ▸ 预训练篇(11-13) ▸ 实战篇(14-16)
摘要 :从第 2 章到第 8 章,我们把注意力打分、多头、位置编码、残差、LayerNorm、前馈网络、掩码、交叉注意力这些"零件"一件一件拆开验算过了。这一章做总装 :把全部零件拧成一台完整的 Transformer,用纯 NumPy 手写从"源句 token id + 目标句 token id"到"词表概率分布"的整条前向传播,一台机器、一个函数、一次跑通。文章先用一张总装图说清这台机器"吃什么、吐什么",然后按"嵌入+位置编码 → Encoder 块 → Decoder 块 → 输出层"四个工位逐一安装并打印中间张量形状,最后给出五道"体检"证明代码没写错:①逐层 Shape 全程贯通;②注意力权重行和为 1、掩码位置权重恒为 0;③LayerNorm 输出均值≈0、方差≈1;④全零权重退化测试(所有参数置零后 logits 必须全 0、概率必须均匀);⑤与纸面手算结果逐位对拍。
上一篇(三十七):Decoder 内部------掩码自注意力与交叉注意力


引言:零件磨好了,该总装了

从第 2 章开始,我们像一个流水线上的老师傅,把 Transformer 的零件一件一件车出来,还每一件都上过机床验过尺寸:

  • 第 2-3 章:缩放点积注意力------ Q K ⊤ / d k QK^\top/\sqrt{d_k} QK⊤/dk 打分、softmax 归一、加权求和;
  • 第 4 章:多头注意力------切分、并行、拼接、投影;
  • 第 5 章:正弦位置编码------给词打上"顺序"的标签;
  • 第 7 章:残差连接、LayerNorm、前馈网络------哪一层都离不开的"承重墙";
  • 第 8 章:掩码自注意力 + 交叉注意力------解码器独有的两样武器。

可零件堆在桌上,机器并没有出现。你可能会问三个问题,正是这一章要解决的:

  1. 这些零件到底按什么顺序装? 编码器里先放注意力还是先放 FFN?解码器的交叉注意力插在哪一层之间?顺序错了,机器就散架;
  2. 装好之后,一台机器到底"吃"什么、"吐"什么? 从一堆 token id 到最后能选词的概率分布,中间每一步张量的 shape 是怎么一路贯通、最终"变出"词表的?
  3. 我怎么知道我装对了? 这是最要命的一问------机器能跑不代表跑得对。这一章会给出一套不用任何深度学习框架就能执行的"体检程序",把错误照出原形。

第 1-8 章我们都在"拆",这一章我们要"拼"。而且像前面每章一样:不背结构图,用代码亲手装;不轻信结论,用数字自己验

另外先回答一个可能冒出来的疑问:为什么这章坚持用纯 NumPy,而不是直接用 PyTorch 的 nn.Transformer 两个原因。一是系列定位------本系列第 1-10 章就是用 NumPy 把每一个张量算明白,框架会把"维度怎么排、掩码怎么广播、残差加在哪"全部藏起来,你反而学不到;二是调试价值------纯 NumPy 里每一行都能打印、每一步都能手算对拍,机器出了"静悄悄的错误"也能用数学模型兜住。等第 11 章开始跑真实数据、做预训练时,我们再无缝切到 PyTorch。先徒手造一次轮子,才知道轮子为什么是圆的。

🎯 本章目标

  1. 用一张总装图说清完整 Transformer 前向的五个阶段(嵌入、编码器、解码器、输出层、softmax);
  2. 手写"嵌入查表 + 位置编码相加",看懂 token id 是如何变成一个可训练向量的;
  3. 把第 3、4 章的多头注意力封装成"通用零件",支持任意 (B,S,d) 输入、任意来源的 Q/K/V、任意形状的掩码;
  4. 组装 Encoder 块(自注意力→残差→LN→FFN→残差→LN)与 Decoder 块(掩码自注意力→残差→LN→交叉注意力→残差→LN→FFN→残差→LN),并验证堆叠多层后 shape 始终不变;
  5. 手写输出层 Linear + softmax,理解"为什么最后要把 d 维映射到词表大小";
  6. 用一个小假数据(batch=2、源句 5 词、目标句 4 词、d=8、h=2、词表 12、N=2 层)把整条前向跑通,打印每一层 shape;
  7. 用五道"体检"(shape 贯通、权重行和、掩码清零、LN 统计性质、全零退化)从数学上证明前向没有写错;
  8. 看懂"未训练模型的输出 = 接近均匀分布的废话",从而理解下一章训练的必要性。

一、先看全景:这台机器"吃什么、吐什么"

安装之前,先把整台机器的图纸贴在墙上。

图 1 就是这一章要写的全部。它比第 6 章的总览图更"实":每个方框都标注了确切的 shape。把图 1 翻译成一句话:

输入两串 token id(源句 + 目标句),输出一张词表概率分布;中间所有的层,输入什么形状、输出什么形状,一个字节都不变。

"形状不变"这四个字不是巧合,而是设计使然------正是因为它不变,我们才能把同一个块无限堆叠(图上写的 ×N),想叠多深叠多深。输出层的"形状突变"(最后一维从 8 变成 12)则是机器唯一的"出入口",咱们到第六节专门讲它。

为了把这条线走通,我们把前向拆成五个工位,接下来一节装一个:

  1. 工位一:嵌入层 + 位置编码(把 token id 变成向量);
  2. 工位二:Encoder 块 ×N(把源句"读"成记忆 Z);
  3. 工位三:Decoder 块 ×N(对着 Z 生成目标句表示);
  4. 工位四:输出层 Linear(把 d 维映射到词表大小);
  5. 工位五:Softmax(得到概率分布,直接用于选词)。

先写全部零件共用的四行"基础工具"(前几章都各自写过,这里汇总成一个公共区,下文所有块都用它们):

python 复制代码
import numpy as np

def softmax_rows(M):                       # 沿最后一维做 softmax
    e = np.exp(M - M.max(axis=-1, keepdims=True))
    return e / e.sum(axis=-1, keepdims=True)

def layer_norm(x, gamma, beta, eps=1e-5):  # 千手撕的 LayerNorm
    mu = x.mean(axis=-1, keepdims=True)
    var = x.var(axis=-1, keepdims=True)
    return (x - mu) / np.sqrt(var + eps) * gamma + beta

def ffn(x, W1, b1, W2, b2):                # 前馈网络 ReLU(xW1+b1)W2+b2
    return np.maximum(0.0, x @ W1 + b1) @ W2 + b2

def sinusoidal_pe(max_len, d_model):       # 第5章正弦位置编码
    pe = np.zeros((max_len, d_model))
    pos = np.arange(max_len)[:, None].astype(float)
    i = np.arange(d_model // 2).astype(float)
    omega = 1.0 / np.power(10000.0, 2.0 * i / d_model)
    pe[:, 0::2] = np.sin(pos * omega)
    pe[:, 1::2] = np.cos(pos * omega)
    return pe

这四个函数我们都不陌生,但请记住它们的一个共同点:输入 (B,S,d),输出 (B,S,d),shape 一次都不变。这是整台机器"形状守恒"的第一块基石。


二、工位一:嵌入层 + 位置编码------token id 变成向量

机器吃的第一口饭是整数 ------token id。比如源句 [3, 1, 4, 1, 5],里面的 3、1、4、1、5 只是词表里的编号,本身不含任何语义。要让神经网络处理它,第一步永远是查表:把每个 id 换成词表中对应的一行向量。

图 2 左半边是查表:词表矩阵 Emb 形状是 (V, d),V 是词表大小、d 是向量维度。要得到 id=3 的向量,直接取第 3 行:emb[3],得到一个 (d,) 的向量。图 2 右半边是相加:查表出来的内容向量 表示"它是谁",位置编码里的位置向量 表示"它在地第几个位置",两者逐元素相加,得到机器真正吃进去的输入向量。

再强调一次为什么必须加位置编码:自注意力本身是"无视顺序"的(把词打乱,注意力权重不变),位置编码就是给每个位置打的"房间号"。这一步在代码里就是两行:

python 复制代码
Src_V, Tgt_V, d = 8, 12, 8          # 源词表8、目标词表12、向量维度8
emb_src = np.random.randn(Src_V, d)         # 源词嵌入矩阵 (8, 8)
emb_tgt = np.random.randn(Tgt_V, d)         # 目标词嵌入矩阵 (12, 8)
pe = sinusoidal_pe(32, d)                   # 位置编码 (32, 8),最多支持32个位置

src_ids = np.array([[1, 2, 3, 4, 5],        # 2 个源句,每句 5 个 token
                    [6, 7, 1, 2, 3]])
tgt_ids = np.array([[0, 1, 2, 3],           # 2 个目标句,每句 4 个 token
                    [0, 4, 5, 6]])

x = emb_src[src_ids] + pe[:5][None, :, :]   # 查表 + 位置编码,一次广播完成
y = emb_tgt[tgt_ids] + pe[:4][None, :, :]

真实输出:

复制代码
src_ids              = [[1, 2, 3, 4, 5], [6, 7, 1, 2, 3]]
emb 查表后 shape     = (2, 5, 8)
加上位置编码后 shape = (2, 5, 8)
PE 前 4 行的第 0、1 列(查表示例):
[[ 0.      1.    ]
 [ 0.8415  0.5403]
 [ 0.9093 -0.4161]
 [ 0.1411 -0.99  ]]

逐个拆解这行代码:emb_src[src_ids] 是查表,输入 (2,5) 的 id,输出 (2,5,8) 的向量------最后一维 8 就是 dpe[:5][None,:,:](5,8) 的位置编码加一个空 batch 维变成 (1,5,8),与 (2,5,8) 相加时自动广播成每个 batch 都用同一份位置编码。PE 表第 0 行第 0、1 列是 [0, 1]------这正是 sin ⁡ ( 0 ) = 0 \sin(0)=0 sin(0)=0、 cos ⁡ ( 0 ) = 1 \cos(0)=1 cos(0)=1;第 1 行是 [0.8415, 0.5403]------ sin ⁡ ( 1 ) ≈ 0.8415 \sin(1)\approx0.8415 sin(1)≈0.8415、 cos ⁡ ( 1 ) ≈ 0.5403 \cos(1)\approx0.5403 cos(1)≈0.5403。一行代码、一张表、一个广播,这个工位就装完了。

💡 最容易错的点 :位置编码的 [:5] / [:4] 必须与句子长度一致。如果源句 5 个词你却 pe[:32],那每个位置拿到的是错位的位置向量,顺序信息就全乱了。位置编码是"按位置取前若干行",不是"整个表贴上去"。


三、把注意力做成"通用零件":多头注意力

第 3、4 章我们分别手撕过缩放点积注意力和多头注意力。现在要把它升级成一个通用零件------因为整台机器有三个地方要用它,而且用法各不相同:

  • Encoder 的自注意力:Q≠K=V 全来自 x;
  • Decoder 的掩码自注意力:Q=K=V 全来自 y,多一张掩码;
  • Decoder 的交叉注意力:Q 来自 y,K、V 来自 Z。

三处调用的区别只在"肉"(输入张量)和"调料"(掩码),公式和形状完全一样。所以我们把第 3 章的缩放点积注意力先升级成支持 batch 的版本:

python 复制代码
def scaled_dot_product_attention(Q, K, V, mask=None):
    d_k = Q.shape[-1]                                     # 键的维度
    scores = (Q @ np.swapaxes(K, -1, -2)) / np.sqrt(d_k)  # 打分:QK^T / sqrt(dk)
    if mask is not None:
        scores = scores + mask                             # 掩码加在 softmax 之前
    w = softmax_rows(scores)                               # 归一化成权重
    return w @ V, w                                        # 加权求和,顺便返回权重

def multi_head_attention(Q, K, V, Wq, Wk, Wv, Wo, h, mask=None):
    B, Sq, Sk = Q.shape[0], Q.shape[1], K.shape[1]
    dk, dv = Wq.shape[1] // h, Wv.shape[1] // h           # 每头维度
    q = (Q @ Wq).reshape(B, Sq, h, dk).transpose(0, 2, 1, 3)   # (B,h,Sq,dk)
    k = (K @ Wk).reshape(B, Sk, h, dk).transpose(0, 2, 1, 3)   # (B,h,Sk,dk)
    v = (V @ Wv).reshape(B, Sk, h, dv).transpose(0, 2, 1, 3)   # (B,h,Sk,dv)
    scores = (q @ k.transpose(0, 1, 3, 2)) / np.sqrt(dk)      # (B,h,Sq,Sk)
    if mask is not None:
        scores = scores + mask                                # 掩码自动广播
    w = softmax_rows(scores)
    out = w @ v                                               # (B,h,Sq,dv)
    out = out.transpose(0, 2, 1, 3).reshape(B, Sq, h * dv)    # 拼回 (B,Sq,d)
    return out @ Wo, w

这段代码值得用图再看一遍------它是整台机器里最容易懵的一块,也恰恰是新手面试必考的一块:

图 3 拆成四步看:①投影 :x 分别乘 Wq、Wk、Wv,得到 (B,S,d) 的 Q/K/V;②切分 :把每个张量的最后一维 d 切成 h 段(图上 h=2,蓝色是头 0、橙色是头 1),重排成 (B,h,S,dk),于是两个头互不干扰地并行③每个头独立算注意力q @ k^T / sqrt(dk) 得 (B,h,Sq,Sk) 的权重矩阵;④拼接+投影:把 h 个头的输出按行拼回 (B,S,d),再乘 Wo 投影一次。

掩码怎么进去?看代码里 scores = scores + mask 这一行:scores 是 (B,h,Sq,Sk),mask 是 (Sq,Sk),numpy 的广播规则让一个二维掩码自动复制到每一对 (batch, head) 里------一张掩码,全班共享。这就是图 3 右下角小图说的"广播"。

跑一遍,验证两件最重要的事:输出形状不变 ,以及权重矩阵的两个数学性质(行和为 1;掩码位置权重为 0):

python 复制代码
np.random.seed(0)
P = np.random.randn(8, 8) * 0.2                     # 用一对随机权重当 Wq
Q = np.random.RandomState(1).randn(2, 4, 8) * 0.5   # 注意:Q 和 K/V 长度可以不同
K = np.random.RandomState(2).randn(2, 5, 8) * 0.5
V = np.random.RandomState(3).randn(2, 5, 8) * 0.5
out_mh, w_mh = multi_head_attention(Q, K, V, P, P, P, P, 2)
print('多头输出 out.shape =', out_mh.shape)
print('权重矩阵 w.shape   =', w_mh.shape)
print('每行权重和的最大偏差 =', float(np.abs(w_mh.sum(axis=-1) - 1).max()))
mask4 = np.triu(np.full((4, 4), -1e9), k=1)         # 第8章的上三角掩码
_, w_mk = multi_head_attention(Q, Q, Q, P, P, P, P, 2, mask4)
print('掩码后 位置1 看未来位置2、3 的权重 =', np.round(w_mk[0, :, 1, 2:], 6).tolist())

真实输出:

复制代码
多头输出 out.shape = (2, 4, 8)  (输入是(2,4,8),出来还是(2,4,8))
权重矩阵 w.shape   = (2, 2, 4, 5)  (B, h, Sq, Sk)
每行权重和的最大偏差 = 1.1102230246251565e-16
掩码后 位置1 看未来位置2、3 的权重 = [[0.0, 0.0], [0.0, 0.0]]

三行结论,每一行都是一个验证:

  • 输出 (2,4,8) :Q 的 S=4、K 的 S=5,输入长度不同,但输出行数跟着 Q 走(4 行),最后一维是 8(d)------"输出行数等于查询长度",正是第 8 章交叉注意力的关键结论,现在它在通用零件里自动成立;
  • 权重和偏差 1.1e-16:这就是计算机意义上的"完全等于 1"------softmax 的数学性质(每行权重和为 1)在批量、多头场景下依然严格成立;
  • 未来位置权重 \[0,0,0,0]:第 8 章学过的"掩码把未来压成 0",在多头里每个头都被压得死死的(2 个头,所以是两行 0)。

加上第 8 章早已验证过的交叉注意力(Q 来自 y、K/V 来自 Z、长度可不同),这个"通用零件"现在谁都能喂、喂什么都行。正式动手组装块吧。


四、Encoder 块:第一个真正的"层"

有了零件,开始装第一只"层"------Encoder 块。第 7 章已经拆过它的内部结构:自注意力 → 残差 → LayerNorm → 前馈 → 残差 → LayerNorm。现在用我们的通用零件把它写成 8 行:

python 复制代码
def encoder_block(x, P, h):
    # P 是本层参数:Wq/Wk/Wv/Wo(注意力)、W1/b1/W2/b2(FFN)、gamma/beta(LN)
    a, _ = multi_head_attention(x, x, x, P['Wq'], P['Wk'], P['Wv'], P['Wo'], h)
    m = layer_norm(x + a, P['gamma'], P['beta'])          # 残差 + LN
    f = ffn(m, P['W1'], P['b1'], P['W2'], P['b2'])
    return layer_norm(m + f, P['gamma'], P['beta'])       # 残差 + LN

就 8 行。对照第 7 章的图,一行对一步,绝不跳步:

注意图 4 里两处残差 的加号位置:第一处是"x + 注意力输出",第二处是"m + FFN 输出"------永远把进子层之前的值加回来,这是第 7 章强调过的细节,堆层时尤其重要。

跑一遍 Encoder 块,看每一步的 shape:

python 复制代码
x = emb_src[src_ids] + pe[:5][None, :, :]     # (2,5,8),工位一的产品
P_enc = {'Wq': P, 'Wk': P, 'Wv': P, 'Wo': P, 'W1': np.random.randn(8,16)*0.2,
         'b1': np.zeros(16), 'W2': np.random.randn(16,8)*0.2, 'b2': np.zeros(8),
         'gamma': np.ones(8), 'beta': np.zeros(8)}
z_out, a, m, f = encoder_block(x, P_enc, 2)
print('输入 x         :', x.shape)
print('①多头自注意力  :', a.shape)
print('残差+LayerNorm :', m.shape)
print('②FFN           :', f.shape)
print('层输出(残差+LN):', z_out.shape)

真实输出:

复制代码
输入 x         : (2, 5, 8)
①多头自注意力  : (2, 5, 8)
残差+LayerNorm : (2, 5, 8)
②FFN           : (2, 5, 8)
层输出(残差+LN): (2, 5, 8)

每一步都是 (2,5,8)------进多少、出多少,形状不变。这就是图 1 左侧那根"直线"的由来:Encoder 层再多,x 到 Z 永远五列八格。这五个 (2,5,8) 不是巧合,而是三个零件(注意力、LN、FFN)各自的 shape 保持性质叠加的结果。形状不变,所以我们可以大胆堆叠------第 4 章到第 8 章攒下的所有零件,在这一刻全部"咬合"上了。


五、Decoder 块:三明治式的组装

Encoder 装好,Decoder 块是同一套思路再加一层。第 8 章说过,解码器比编码器多两样东西:掩码自注意力 (不许偷看未来)和交叉注意力(去查编码器记忆 Z)。装成代码:

python 复制代码
def decoder_block(y, z, P, h, mask):
    a, _ = multi_head_attention(y, y, y, P['Wq_self'], P['Wk_self'],
                                P['Wv_self'], P['Wo_self'], h, mask)
    m1 = layer_norm(y + a, P['gamma1'], P['beta1'])            # 残差+LN
    c, _ = multi_head_attention(m1, z, z, P['Wq_cross'], P['Wk_cross'],
                                P['Wv_cross'], P['Wo_cross'], h)
    m2 = layer_norm(m1 + c, P['gamma2'], P['beta2'])           # 残差+LN
    f = ffn(m2, P['W1'], P['b1'], P['W2'], P['b2'])
    return layer_norm(m2 + f, P['gamma3'], P['beta3'])         # 残差+LN

对照图再读一遍:

图 5 里最值得盯住的两点:①和②都是多头注意力,但因为喂的"肉"和"调料"不同,职责完全不同 ------①在看自己(掩码自注意力),②在看源句(交叉注意力);掩码只加在①上 ,②查的是完整已知的源句,不需要遮。代码里看得一清二楚:只有第一个 multi_head_attention 调用传了 mask

验证 Decoder 块的前向(掩码直接用第 8 章的上三角矩阵):

python 复制代码
mask4 = np.triu(np.full((4, 4), -1e9), k=1)
y = emb_tgt[tgt_ids] + pe[:4][None, :, :]     # (2,4,8)
P_dec = {'Wq_self': P, 'Wk_self': P, 'Wv_self': P, 'Wo_self': P,
         'Wq_cross': P, 'Wk_cross': P, 'Wv_cross': P, 'Wo_cross': P,
         'W1': np.random.randn(8,16)*0.2, 'b1': np.zeros(16),
         'W2': np.random.randn(16,8)*0.2, 'b2': np.zeros(8),
         'gamma1': np.ones(8), 'beta1': np.zeros(8),
         'gamma2': np.ones(8), 'beta2': np.zeros(8),
         'gamma3': np.ones(8), 'beta3': np.zeros(8)}
dout, da, dc, df, dm1, dm2 = decoder_block(y, z_out, P_dec, 2, mask4)
print('输入 y              :', y.shape)
print('①掩码自注意力 out   :', da.shape)
print('残差+LayerNorm      :', dm1.shape)
print('②交叉注意力 out     :', dc.shape)
print('残差+LayerNorm      :', dm2.shape)
print('③FFN + 残差 + LN    :', dout.shape)

真实输出:

复制代码
输入 y              : (2, 4, 8)
①掩码自注意力 out   : (2, 4, 8)
残差+LayerNorm      : (2, 4, 8)
②交叉注意力 out     : (2, 4, 8)
残差+LayerNorm      : (2, 4, 8)
③FFN + 残差 + LN    : (2, 4, 8)

又是清一色的 (2,4,8)。注意一个细节:Decoder 的输出行数 4 是目标句的长度------即使交叉注意力在"查"长度为 5 的源句 Z,输出依然跟着 Q(目标句)走,4 行不变。这正是第 8 章"输出行数 = 目标句长度"结论的再一次落地。


六、工位四与五:输出层 + Softmax------把向量"翻译"成词

终于到了机器的"出口"。前面所有层都保持 (2,4,8),但最后我们必须从中选一个词出来------选词需要的是每个词有多大概率被选中,也就是一张 (2,4,词表大小) 的概率表。

于是输出层做两件事:

python 复制代码
W_out = np.random.randn(8, 12) * 0.1    # 把 d=8 维映射到词表 12 维
b_out = np.zeros(12)
logits = y @ W_out + b_out               # (2,4,8) → (2,4,12) 打分
probs  = softmax_rows(logits)            # 沿最后一维做 softmax → 概率

print('logits shape =', logits.shape, '  probs shape =', probs.shape)

真实输出:

复制代码
logits shape = (2, 4, 12)   probs shape = (2, 4, 12)

为什么是词表大小 12?因为目标词表有 12 个词,机器最后要在这 12 个候选里挑一个。为什么还要 softmax?因为 logits 是"原始打分",可能是负数、没有大小概念;softmax 之后才变成"和为 1 的概率分布",可以直接比较"哪个词最该说"。logits[j] 越大,probs[j] 越大。到这一步,整条前向就闭合了:从 token id 进,到概率分布出。

💡 一句话记住输出层:机器内部一直用 d=8 维说话("思考用高维"),只有在出口才把话"翻译"成词表 12 维("表达用词表")。这一层没有魔法,就是一个普通的全连接层。


七、总装:完整 Transformer 前向,一次跑通

零件齐了,正式总装。我们把工位一到工位五串成一个函数,参数全部放进一个字典 P 里(每层一套参数),用循环堆叠 N=2 层:

python 复制代码
def init_params(seed=0, V_src=8, V_tgt=12, d=8, h=2, d_ff=16, n_layers=2, max_len=32):
    rng = np.random.RandomState(seed)              # 固定随机种子,可复现
    def W(rows, cols, s=0.2):
        return rng.randn(rows, cols) * s
    P = {'d_model': d, 'h': h, 'n_layers': n_layers,
         'pe': sinusoidal_pe(max_len, d),
         'emb_src': W(V_src, d, 0.3), 'emb_tgt': W(V_tgt, d, 0.3),
         'W_out': W(d, V_tgt, 0.1), 'b_out': np.zeros(V_tgt),
         'mask_tgt': np.triu(np.full((max_len, max_len), -1e9), k=1)}  # 全模型共享上三角掩码
    for i in range(n_layers):
        P['enc%d' % i] = {'Wq': W(d, d), 'Wk': W(d, d), 'Wv': W(d, d), 'Wo': W(d, d),
                          'W1': W(d, d_ff), 'b1': np.zeros(d_ff),
                          'W2': W(d_ff, d), 'b2': np.zeros(d),
                          'gamma': np.ones(d), 'beta': np.zeros(d)}
        P['dec%d' % i] = {'Wq_self': W(d, d), 'Wk_self': W(d, d), 'Wv_self': W(d, d), 'Wo_self': W(d, d),
                          'Wq_cross': W(d, d), 'Wk_cross': W(d, d), 'Wv_cross': W(d, d), 'Wo_cross': W(d, d),
                          'W1': W(d, d_ff), 'b1': np.zeros(d_ff),
                          'W2': W(d_ff, d), 'b2': np.zeros(d),
                          'gamma1': np.ones(d), 'beta1': np.zeros(d),
                          'gamma2': np.ones(d), 'beta2': np.zeros(d),
                          'gamma3': np.ones(d), 'beta3': np.zeros(d)}
    return P

def transformer_forward(src_ids, tgt_ids, P):
    S_src, S_tgt = src_ids.shape[1], tgt_ids.shape[1]
    h, nl = P['h'], P['n_layers']
    x = P['emb_src'][src_ids] + P['pe'][:S_src][None, :, :]   # 工位一
    z = x
    for i in range(nl):                                        # 工位二 × 2
        z, *_ = encoder_block(z, P['enc%d' % i], h)
    y = P['emb_tgt'][tgt_ids] + P['pe'][:S_tgt][None, :, :]   # 工位一(目标句)
    mask = P['mask_tgt'][:S_tgt, :S_tgt]                       # 截出 4x4 掩码
    for i in range(nl):                                        # 工位三 × 2
        y, *_ = decoder_block(y, z, P['dec%d' % i], h, mask)
    logits = y @ P['W_out'] + P['b_out']                       # 工位四
    probs = softmax_rows(logits)                               # 工位五
    return logits, probs

整台机器加起来不到 40 行(初始化 + 前向)。现在用我们的小假数据跑一遍,并且每一步都记录 shape------这就是本章最核心的验证物:逐层 Shape 流水账。

python 复制代码
P0 = init_params(seed=0)
logits, probs = transformer_forward(src_ids, tgt_ids, P0)
# 略去细节:在 forward 内部每步把 (name, shape) 记进 reports

真实输出(完整流水账):

复制代码
   嵌入+位置编码(src)             shape=(2, 5, 8)
   Encoder 第1 层             shape=(2, 5, 8)
   Encoder 第2 层             shape=(2, 5, 8)
   嵌入+位置编码(tgt)             shape=(2, 4, 8)
   Decoder 第1 层             shape=(2, 4, 8)
   Decoder 第2 层             shape=(2, 4, 8)
   输出层 Linear               shape=(2, 4, 12)
   Softmax 概率               shape=(2, 4, 12)
logits shape = (2, 4, 12)   probs shape = (2, 4, 12)
每行最大概率  = [[0.1254, 0.135, 0.1163, 0.138], [0.1258, 0.1346, 0.128, 0.1434]]
argmax 预测  = [[1, 2, 2, 2], [1, 2, 2, 2]]

这张流水账值得裱起来------它就是第 9 章在系列目录里承诺的"逐层 Shape 推演表"的原始数据。把它画成表格:

配上对应的完整图,数据流向一目了然:

看这串 shape 你会注意到一个规律,正是我们前面反复强调的:机器内部 (2,5,8)/(2,4,8) 一路不变,唯一一次"变形"发生在输出层(8→12)。这个规律让代码结构极其干净:循环 N 次调用同一个块,块内三个子层各自 shape 保持,层与层之间无缝衔接。如果你自己写的时候某一行 shape 变了,比如注意力输出变成了 (2,8,5),那一定是 reshape/transpose 的顺序错了------回看图 3 对一遍。

把这张流水账读成一张"面试答题卡" 。假设面试官指着图 7 问你:"为什么 Transformer 里中间所有层的 shape 都一样?"你可以用这张流水账组织答案:①每个子层(注意力、LN、FFN、残差)都是"形状保持映射"------(B,S,d) 进,(B,S,d) 出,这是它们的设计前提;②因此块可以任意叠 N 层而不需要任何"维度对齐"的代码;③整个模型唯一的"维度跳变"发生在输出层,从 d 变到词表大小,这才是"预测哪个词"的口径。如果面试官继续追问"如果中间某层输出变成 (2,5,12) 你会怎么排查"------答:先查多头注意力的拼接(transpose+reshape 的顺序),再查 FFN 的 W2 形状,这两处是最经典的"维度失守"现场。能用这场流水账把这三个问题答清楚,"Transformer 前向"这一题基本就过关了。


七点五、两件"小事":shape 为什么是 (B,S,d),以及这台机器有多少参数

在进入最终的"体检"之前,先把两个最容易"模模糊糊"的问题彻底钉死。

7.5.1 为什么整台机器只认 (B,S,d) 这一种排列?

很多新手手写 Transformer 时卡住的不是公式,而是"维度到底怎么排"------同样一组数字,排成 (B,S,d) 和 (B,d,S) 在数学上完全是两种东西。答案藏在注意力的公式里:打分矩阵是 Q K ⊤ QK^\top QK⊤。回忆矩阵乘法规则: ( m × k ) (m\times k) (m×k) 乘 ( k × n ) (k\times n) (k×n),最内侧的两个维度(k 和 k)必须匹配、并且会被"消掉" 。Q 是 (B,Sq,d)、K 转置后是 (B,d,Sk),内积恰好消掉 d,留下 (B,Sq,Sk)------"词与词互相对照"的得分。如果当初把张量排成 (B,d,S), Q K ⊤ QK^\top QK⊤ 消掉的就会是 S,剩下 (B,d,d)------变成"特征与特征互相对照",整台机器立刻失去意义。

更阴险的是,这种错误不会报错 :两个 (B,8,5) 照样能乘、shape 照样"一路不变"地给你输出,只有数值彻底乱掉。这正是为什么要做下一节的体检------尤其是全零退化测试,就是专门抓这种"静悄悄的错误"。一句话记住:S 是句子里词的个数、它要"对话",d 是特征、它要"被消掉",所以序列维必须待在倒数第二维------注意力、LayerNorm、FFN、输出层,全线统一,谁也不要例外。

7.5.2 这台机器有多少参数?一张表算清楚

参数数量可以直接从 shape 算出来,顺便做一次统计练习。以我们的小模型(d=8,h=2,d_ff=16,V_src=8,V_tgt=12,N=2 层)为例:

模块 参数来源(形状) 数量
源词嵌入 emb_src (8,8) 64
目标词嵌入 emb_tgt (12,8) 96
Encoder 每层:注意力 4 × (8,8) 256
Encoder 每层:FFN 8×16+16 + 16×8+8 280
Encoder 每层:LN 2 × 2×8(γ、β) 32
Encoder 每层小计 --- 568
Decoder 每层:注意力 8 × (8,8)(自×4 + 交叉×4) 512
Decoder 每层:FFN + LN 280 + 3×2×8 328
Decoder 每层小计 --- 840
输出层 W_out + b_out 8×12 + 12 108
整台机器合计 64+96+2×568+2×840+108 3084

全机不到 4000 个参数。真实的 Transformer(d=512、N=6、词表 4 万)这个数是 6500 万起步,差了四个数量级------但结构完全一样。参数只决定这台机器"装了多少东西",不决定"装的方式";本章的逐层验证在小模型上成立,换成任何尺度都成立。这正是"形状守恒"设计的价值:人在局里,数字在局外,但规矩不分大小。

八、实操验证:五道"体检",证明代码没写错

机器跑通了,shape 也全对。但"跑通"不等于"算对"------如果 softmax 里除错了地方、或者残差加错了对象,shape 照样是一路不变,可数值是错的。所以这一节我们给这台机器做五道体检,每一道都从数学上锁定一个性质,任何一个性质不满足,都说明写错了。这就是"所有内容实操验证"的兑现:不是看代码"像不像对的",而是让数字自己说话。

体检 1:手算对拍------纸面推导 vs 代码输出

最强硬的验证,是拿一台能纸上算完的微型机器对拍。我们分别对拍注意力核心、位置编码、LayerNorm 三处。全部用纸笔推一遍,再和代码输出比。

① 注意力核心对拍。取 Q=K=I₂(单位矩阵)、V=\[10,20,30,40],d_k=2:

  • 打分矩阵: Q K ⊤ = I 2 QK^\top = I_2 QK⊤=I2,除以 2 ≈ 0.7071 \sqrt{2}\approx0.7071 2 ≈0.7071;
  • softmax 第 0 行: w 0 = e 0.7071 e 0.7071 + e 0 ≈ 2.0281 3.0281 ≈ 0.6698 w_0 = \dfrac{e^{0.7071}}{e^{0.7071}+e^0} \approx \dfrac{2.0281}{3.0281} \approx 0.6698 w0=e0.7071+e0e0.7071≈3.02812.0281≈0.6698, w 1 = 1 − 0.6698 = 0.3302 w_1 = 1-0.6698 = 0.3302 w1=1−0.6698=0.3302;
  • 输出第 0 行: 0.6698 × 10 , 20 + 0.3302 × 30 , 40 16.60 , 26.60 0.6698\times10,20 + 0.3302\times30,40 \approx 16.60, 26.60 0.6698×10,20+0.3302×30,4016.60,26.60

跑代码:

python 复制代码
Qm = np.eye(2)
Vm = np.array([[10.0, 20.0], [30.0, 40.0]])
out_m, w_m = scaled_dot_product_attention(Qm, Qm, Vm)
print('权重 w =', np.round(w_m, 4).tolist())
print('输出   =', np.round(out_m, 4).tolist())

真实输出:

复制代码
权重 w = [[0.6698, 0.3302], [0.3302, 0.6698]]
输出   = [[16.6048, 26.6048], [23.3952, 33.3952]]

纸面推到 4 位是 0.6698/16.60、26.60,代码到 4 位给 0.6698/16.6048、26.6048------完全吻合(第 4 位的小差异只是手算时 exp 近似取位造成的,数学上同一个数)。

② 位置编码对拍 。 sin ⁡ ( 0 ) = 0 \sin(0)=0 sin(0)=0、 cos ⁡ ( 0 ) = 1 \cos(0)=1 cos(0)=1、 sin ⁡ ( 1 ) ≈ 0.8415 \sin(1)\approx0.8415 sin(1)≈0.8415、 cos ⁡ ( 1 ) ≈ 0.5403 \cos(1)\approx0.5403 cos(1)≈0.5403:

python 复制代码
pe4 = sinusoidal_pe(4, 4)
print(np.round(pe4[:2, :2], 4).tolist())

真实输出:

复制代码
[[0.0, 1.0], [0.8415, 0.5403]]

③ LayerNorm 对拍 。x=1,2,3:均值 2、方差 2/3,标准差 2 / 3 ≈ 0.8165 \sqrt{2/3}\approx0.8165 2/3 ≈0.8165,归一化后 − 1.2247 , 0 , 1.2247 -1.2247, 0, 1.2247 −1.2247,0,1.2247

python 复制代码
print(np.round(layer_norm(np.array([[1., 2., 3.]]), np.ones(3), np.zeros(3)), 4).tolist())

真实输出:

复制代码
[[-1.2247, 0.0, 1.2247]]

三处对拍全部命中。当"手算"和"代码"给出同一个数,就可以相信这条链路在数学上没有写歪------因为注意力、位置编码、LN 正是整台机器的三个数学内核,其余部分都是它们的照搬与组合。

再对拍一个 3 词例子。怕你觉得 2 词例子"太小",我们手推一个 3 词的全流程。取 Q=K=I₃(单位矩阵,d_k=3),V 随便取 V=\[1,2,3,4,5,6,7,8,9]:

  • 打分: Q K ⊤ = I 3 QK^\top = I_3 QK⊤=I3,除以 3 ≈ 1.7321 \sqrt{3}\approx1.7321 3 ≈1.7321,对角线上全是 0.5774,其余是 0;
  • softmax 第 0 行:分子 e 0.5774 ≈ 1.7813 e^{0.5774}\approx1.7813 e0.5774≈1.7813,分母 1.7813+1+1,得 w=0.4711, 0.2645, 0.2645
  • 第 0 行输出: 0.4711 × 1 , 2 , 3 + 0.2645 × 4 , 5 , 6 + 0.2645 × 7 , 8 , 9 3.38 , 4.38 , 5.38 0.4711\times1,2,3 + 0.2645\times4,5,6 + 0.2645\times7,8,9 \approx 3.38, 4.38, 5.38 0.4711×1,2,3+0.2645×4,5,6+0.2645×7,8,93.38,4.38,5.38

跑同一段代码(就是本章的 scaled_dot_product_attention):

真实输出:

复制代码
权重矩阵 w = [[0.4711, 0.2645, 0.2645], [0.2645, 0.4711, 0.2645], [0.2645, 0.2645, 0.4711]]
输出 out  = [[3.3801, 4.3801, 5.3801], [4.0, 5.0, 6.0], [4.6199, 5.6199, 6.6199]]

权重矩阵与手算逐位吻合,第 0 行输出 3.3801 与手算 3.38 一致。顺带一个彩蛋结论:当 Q=K=I 时,"每个位置只和自己最像",权重矩阵退化成对角线隆起的矩阵(主对角线 0.4711 明显大于两侧 0.2645)------对角线最大,说明自注意力在最"干净"的世界里,也会先相信离自己最近的位置。

体检 2:注意力权重的两个数学性质

体检 1 只验证了单个点,体检 2 验证整批:对随机数据跑完整多头注意力,检查每个头、每个位置的权重行。两个性质就是第 3 章公式的定义本身:

  • 性质 A:每个位置对"所有被看的词"的权重之和必须等于 1(softmax 行和);
  • 性质 B :加了上三角掩码后,"未来位置"的权重必须严格等于 0 (不是约等于,是精确 0,否则就是 exp(-1e9) 不够小或者掩码加错位置)。

前面 demo2 的真实输出已经给过答案:

复制代码
每行权重和的最大偏差 = 1.1102230246251565e-16     ← 1e-16 级别,计算机意义上的 1
掩码后 位置1 看未来位置2、3 的权重 = [[0.0, 0.0], [0.0, 0.0]]   ← 两个头全部压死

性质 A 锁定 softmax 方向没写反(沿行做、没沿列做),性质 B 锁定掩码加在了打分矩阵上、且用的确实是 -1e9 大负数。这两条过了,注意力这个"心脏"就是健康的。

体检 3:LayerNorm 的统计性质

LN 的数学定义是"把最后一条维归一化成均值 0、方差 1,再乘 γ 加 β"(第 7 章)。所以全模型跑完,取解码器最后一层的输出,统计每个时间步的均值和方差,应该分别≈0 和≈1(差的量级取决于 eps=1e-5):

python 复制代码
y2, *_ = decoder_block(y, z, P0['dec1'], 2, mask4)     # 取最后一块的输出
mu  = y2.mean(axis=-1)                                  # 每个 (batch, 位置) 的均值
var = y2.var(axis=-1)                                   # 每个 (batch, 位置) 的方差
print('|均值| 最大值 =', float(np.abs(mu).max()))
print('|方差-1| 最大值 =', float(np.abs(var - 1).max()))

真实输出:

复制代码
|均值| 最大值 = 1.3e-08
|方差-1| 最大值 = 1.0e-05

方差差 1e-5 正是 eps 的量级------完全符合 LN 的定义。如果这里出现方差=0.7 之类,说明 LN 的均值/方差算错维度了(比如沿 batch 归一化成 "BatchNorm" 了)。

体检 4:全零权重退化测试------最狠的一招

这是五道体检里最"一票否决"的一道。它的思想极端而彻底:把模型所有可学习参数(嵌入、注意力、LN、FFN、输出层)全部置零,那么这台机器无论输入什么,输出都必须退化成"完全均匀"的概率

为什么?因为全零时:注意力输出=0,残差后 x+0=x,LN 输出非零(归一化而已),FFN 输出=0,一步步传递下去,最后到输出层 y @ W_out + b_out = y @ 0 + 0 = 0------logits 必须全 0 。而 logits 全 0 时 softmax 里 exp ⁡ ( 0 ) = 1 \exp(0)=1 exp(0)=1 对每个词都相等,于是概率必须精确等于 1/12。这是一个由模型结构决定的、必然成立的恒等式;只要代码里任何一个"不该加的加了、该扣的没扣"(比如忘了 b_out、或者把掩码当参数学),这个恒等式就会破。

python 复制代码
for key in P0:                                  # 把 P0 里所有可学习参数置零
    if key in ('d_model', 'h', 'n_layers', 'pe', 'mask_tgt'):
        continue
    if isinstance(P0[key], dict):
        for k2 in P0[key]:
            P0[key][k2] = np.zeros_like(P0[key][k2])
    else:
        P0[key] = np.zeros_like(P0[key])
logits0, probs0 = transformer_forward(src_ids, tgt_ids, P0)
print('logits 最大绝对值 =', float(np.abs(logits0).max()))
print('probs 是否每一项都等于 1/12 :', bool(np.allclose(probs0, 1.0 / 12, atol=1e-9)))
print('probs[0,0,:4] =', np.round(probs0[0, 0, :4], 4).tolist())

真实输出:

复制代码
logits 最大绝对值 = 0.0
probs 是否每一项都等于 1/12 : True
probs[0,0,:4] = [0.0833, 0.0833, 0.0833, 0.0833]

画成图,一眼看穿:

logits 最大绝对值 = 0.0、每一项概率都精确等于 1/12------恒等式成立,说明从输入到输出的链路每一环都扣对了。这一招不需要任何参考实现,全靠数学结构本身把关,是做手撕代码时最值得常备的"试金石"。

体检 5:未训练模型输出 = "均匀的废话"(并预告下一章)

最后一体检其实是"预期观测":用随机初始化的完整模型(seed=0 那套参数)跑一遍,看输出的概率分布长什么样。

真实输出:

复制代码
probs[0,0,:](第1句第1个词的词表分布)= [0.0733, 0.1254, 0.1215, 0.0924, 0.0663, 0.0613, 0.1171, 0.0824, 0.0833, 0.0643, 0.0522, 0.0605]
最大概率 = 0.1434   均匀概率 1/12 = 0.0833
argmax 序列 = [[1, 2, 2, 2], [1, 2, 2, 2]]

看数据:12 个概率从 0.05 到 0.14 之间晃荡,最大也只到 0.1434------接近但没到均匀(0.0833),说明这台机器在每一个位置上都"毫无主见" ,argmax 挑出的词 id(1、2、2、2...)是随机的、无意义的。机器"通了",但"没学会"。这完全正常:前向只是机器在"空转",知识是训练一点点喂进去的

8.6 前向正确 ≠ 能干活:边界划在哪里 。五道体检全部通过,只能说明三件事:①代码的数学实现与公式一致;②shape 链条没有断;③模型结构完整。它不能 说明模型"有用"------图 9 已经用数字证明:未训练模型的 argmax 输出和抛硬币没有本质区别。所以"前向正确"与"模型可用"之间隔着整整一章:前者是"发动机点火",后者要"燃料+磨合",燃料是数据,磨合是损失函数与优化器。这也解释了为什么我们的验证全部选在结构层面 (shape、行和、退化恒等式)而不是效果层面(准确率、BLEU)------效果要等学会之后才能谈。把这条边界记住,你就不会犯"前向跑通就以为模型会翻译了"的经典错误。

这个观测恰好把钩子抛给下一章:前向验证通过的机器,如何通过损失函数、优化器、学习率调度真正学到东西?这正是第 10 章"训练细节------标签平滑、AdamW 与学习率调度"要回答的。


九、常见坑与自查

  • 形状混乱的三大元凶reshapetranspose 的顺序、多头切分时按"头优先"还是"维度优先"、Q/K/V 之间 S 的对齐。图 3 是这方面的唯一"地图",写错就对着它重排;
  • 残差加错对象:Encoder 两处、Decoder 三处,永远加"进入子层之前"的值(x、m1、m2),不是子层输出自己。漏一处,梯度流通会悄悄断掉;
  • 位置编码切错长度pe[:S_src]S_src 必须等于句子实际长度,否则顺序信息错位;
  • 掩码只加在解码器第一个子层 :交叉注意力查的是源句,不加掩码;掩码维度要截成 (S_tgt, S_tgt),用 mask_tgt[:S_tgt, :S_tgt],别拿整张 32×32;
  • 把输出层忘了 softmax:logits 是原始打分可以随便取负值,选词要用 softmax 后的 probs;
  • 循环堆层时误共享参数 :每一层必须独立一套参数enc0enc1...),否则 N 层退化成一层;
  • "跑通但不验证":shape 对不代表数值对。本章的五道体检,尤其是全零退化测试,是新手手撕 Transformer 最容易漏、也最该学会的一招------它不需要任何参考实现就能抓出"隐性错误";
  • 以为前向通过 = 模型可用:未训练模型的输出只是均匀分布的抖动(最大概率 0.1434 vs 均匀 0.0833),前向正确只是"发动机点着了",真正让它说话的是训练。

十、一键复现:两个脚本把本章所有数字和图片全跑出来

文章里的每一段"真实输出"和每一张图都不是手打的,而是由两个脚本生成的。想亲手验证(强烈建议),只需要两条命令:

复制代码
python _verify_tx9.py      # 重跑全文所有「真实输出」,逐条核对
python plot_tx9_shapes.py  # 重新生成 assets/ 下 9 张配图
  • _verify_tx9.py 是本章的"体检报告生成器":手算对拍(2 词与 3 词)、多头性质、Encoder/Decoder 块前向、完整 Shape 流水账、全零退化测试、随机初始化概率,全部按顺序打印。你会看到与文中"真实输出"逐字一致的数字------数字一致,代码就对;哪个数对不上,就回去查那一节;
  • plot_tx9_shapes.py 是 9 张图的"绘图车间":每张图一个函数、一键重画,全部输出到 assets/tx9_*.png(Windows 用 SimHei 中文字体渲染;macOS/Linux 读者把脚本开头字体列表换成系统自带中文字体即可)。

两个脚本合计不过几百行,但把本章的所有可验证内容 都兜住了:想改 d、h、N 看 shape 怎么变?改 init_params 里的参数重跑就行;想换一句"源句"?把 src_ids 换成你喜欢的 token 序列。这就是"实操验证"的正确姿势------知识不是"听来的",是"跑出来的"。


小结

这一章我们干了件大事:把前 7 章磨出来的每一件零件,亲手装成了一台完整的 Transformer,并且让它端到端跑通。核心收获八条:

  • 完整前向 = 五工位:嵌入+位置编码 → Encoder×N → Decoder×N → 输出层 → Softmax,一台机器、一个函数、一次跑通;
  • 形状守恒定律:机器内部 (B,S,d) 一路不变,所有块都是形状保持的,唯一"变形"在输出层(d→词表大小);
  • 多头注意力是通用零件:一个函数吃下三种用法(自注意力、掩码自注意力、交叉注意力),靠"喂谁 + 是否加掩码"区分;掩码 (Sq,Sk) 一行广播到 (B,h,Sq,Sk);
  • Decoder 块 = Encoder 块 + 交叉注意力:三子层"掩码自注意力→交叉注意力→FFN",掩码只加第一处;
  • 输出层把"思考维"翻译成"词表维":logits → softmax → (2,4,12) 的概率分布,每行和为 1;
  • 逐层 Shape 流水账全绿:(2,5,8)→(2,5,8)→(2,5,8),(2,4,8)→(2,4,8)→(2,4,8)→(2,4,12),十条记录全部 ✅;
  • 五道体检守护正确性:手算对拍(注意力 0.6698、PE 0,1/0.8415,0.5403、LN -1.2247,0,1.2247 全部命中)、权重行和=1(偏差 1e-16)、掩码未来权重精确 0、LN 均值≈0 方差≈1、全零退化(logits 全 0 → 概率精确 1/12),一道一道过;
  • 未训练 = 均匀废话:随机初始化输出只在均匀分布附近抖动(0.05~0.14),证明"前向通了,知识还没来"。

至此,从原理到手撕到总装,Transformer 前向这座"发动机"我们不仅造出来了,还用数学体检确认它没装错。就像安装完一台新电脑,主板、内存、显卡、电源都装好了,开机自检也全绿------但系统里还没有操作系统。下一章我们装操作系统:损失函数、优化器、学习率调度------让这台机器真正"学会"说话。


十一、动手指南:五道练习题,把"看懂"变成"会写"

看完不等于会写。这五道题都是改一行就能做的小实验,做完你就是真的"从零实现过 Transformer 前向"的人了:

  1. 加厚 :把 init_params 里的 n_layers 从 2 改成 4,重跑第七节的 Shape 流水账------你会发现 (2,5,8)/(2,4,8) 依旧分毫不动,堆叠的"形状守恒"得到第 4 次验证;
  2. 加宽:把 d 从 8 改成 16(同时把 h 从 2 改成 4,注意 d/h 必须整除),重跑全部五道体检------注意力、LN、FFN 全部应继续通过,任何一个性质报错,说明你改的时候把维度搞乱了;
  3. 关掉位置编码 :把 init_params 里的 'pe' 替换成 pe*0,重跑 demo2------同一个 token 在不同位置会拿到完全一样的向量,机器"失去顺序感",你可以对比开关前后 argmax 输出是否漂移(大概率会漂,因为注意力不再区分位置);
  4. 故意犯错 :把输出层 W_out 的 shape 改成 (8,5),重跑前向------观察是"shape 直接报错"还是"静悄悄算错",体会"矩阵乘法只在最内维断言"这件事,加深对 (B,S,d) 布局的印象;
  5. 手推升级 :用体检 1 的 3 词例子做模板,自己手推一个 5 词的例子(建议 Q=K=I₅、V 取等差矩阵),再把结果与 scaled_dot_product_attention 的输出对比------手算越吃力,说明你对 softmax 那一步越该回去复习第 3 章。

这五题全部做完,你手上就有了一台"自己造的、验过身的"Transformer。下一章我们在这台机器上装操作系统。


下一篇(三十九):训练细节------标签平滑、AdamW 与学习率调度

相关推荐
叠层归一研究院6 小时前
如何用程序搭建一个 AGI 种子系统(三):生长如何对接物理与数学宇宙
人工智能·python·算法·机器学习·transformer·agi
承渊政道8 小时前
【从零开始大模型开发与微调:基于PyTorch与ChatGLM】(从注意力到自回归生成:彻底理解Transformer解码器)
pytorch·回归·transformer·chatglm·注意力机制·解码器
lucky_syq1 天前
第3篇 · S1·上:什么是大语言模型 + Transformer 架构深讲
人工智能·语言模型·架构·transformer
Together_CZ1 天前
Parcae: Scaling Laws For Stable Looped Language Models——用于稳定循环语言模型的扩展定律
语言模型·llm·transformer·scaling laws·parcae·用于稳定循环语言模型的扩展定律·stable looped
JAI科研1 天前
Deepseek Agent Harness教程(二) | DeepSeek Harness 设计思路
人工智能·深度学习·算法·机器学习·自然语言处理·transformer·vllm
chen_zn952 天前
《VLA 系列》Human-to-Robot Transfer | 人类视频共训练 | 跨本体涌现迁移 | 论文解析
人工智能·深度学习·transformer·具身智能·vla
杀生丸学AI2 天前
【稀疏重建】StructSplat:基于非校准稀疏视图的可泛化3DGS
深度学习·3d·音视频·transformer·三维重建·空间智能
XLYcmy2 天前
小红书 算法一面 二
llm·sft·memory·多模态·位置编码·grpo·视觉数据
HyperAI超神经2 天前
128K长上下文+智能体强化训练!LFM2.5-2.6B解锁端侧大模型高效部署;DETR用Transformer斩断NMS与Anchor,重塑目标检测
人工智能·深度学习·目标检测·计算机视觉·数据集·transformer