“手撕Transformer”,这句话在深度学习圈子里基本等同于“我要彻底搞懂它,而不是只会调库”。很多人学Transformer,第一反应是读论文、看讲解图,一看就懂,一写就废。真正动手从零开始实现一遍,你才会发现:为什么Q和K要除以根号d?为什么位置编码用正弦函数?训练时loss不降,到底是哪里出了问题?这篇文章我会带你忽略掉所有花哨的封装,从第一性原理出发,把Transformer的每一个核心环节拆开、揉碎,并用完整的Python代码搭建一个可训练的中英翻译模型。这篇不是论文复现,而是我个人踩坑无数后的实操总结,保证够“干”。
在开始之前,建议你先准备好基础知识:熟悉Python、了解基本的深度学习概念(损失函数、反向传播)、装好PyTorch。我们的目标很纯粹:用大约300行核心代码,让一个从零手写的Transformer真正跑起来,在微型数据集上看到loss明显下降、翻译结果逐渐靠谱的过程。
1. 内容整体设计与思路拆解
1.1 什么是“手撕Transformer”,我们到底在撕什么
市面上讲Transformer的资料多如牛毛,但绝大多数是拿现成的nn.Transformer或HuggingFace的封装“调包侠”。直接调包当然能出结果,但自己手写一遍,你会被迫回答很多“灵魂拷问”:
- 自注意力机制为什么能捕捉长距离依赖?它是如何对序列中每个词进行全局建模的?
- Mask(掩码)有好几种,padding mask和look-ahead mask到底分别防什么?用错一个,模型直接“作弊”给你看。
- 残差连接和LayerNorm的位置为什么在多头注意力之后?顺序换了行不行?
- 训练的时候,Decoder的输入是什么?推理的时候,为什么又要一个一个词地蹦?
这些问题的答案,论文里有,但只有亲手实现过,才会变成你脑子里真正理解的知识。
本项目的核心思路非常明确:用最小可运行的代码,实现论文中Transformer的完整结构。具体来说,我们做三件事:
- 用PyTorch从零实现Encoder(编码器)和Decoder(解码器)的每一个组件:词嵌入、位置编码、多头注意力、前馈网络、LayerNorm、残差连接。
- 构造一个真实可训练的任务:中文到英文的短句翻译(当然,受限于算力,是微型数据集)。
- 让整套代码跑通,并展示训练过程中的观察和DEBUG方法。
最终你会得到一个虽然没有工业级表现、但结构完全正确的Transformer模型。把这份代码吃透之后,再去读任何基于Transformer的大模型(BERT、GPT等)源码,你会发现一切都变得熟悉。
1.2 方案选型:为什么选择从零实现的PyTorch版本
很多人会问:既然nn.Transformer都封装好了,为什么还要自己造轮子?我的回答是:“自己造轮子”不是为了产出最好的模型,而是为了让自己成为那个“能造轮子的人”。
从技术选型上,我坚持用PyTorch而不是TensorFlow,原因很简单:
- PyTorch的自动求导机制非常直观,代码写起来接近“数学定义”,适合教学和理解。
- 调试手段丰富,可以随时打印中间张量的shape,这对手撕模型是“救命稻草”。
这里需要说明一下,模型结构我参考了经典开源项目 “Annotated Transformer” 的实现思路,并在此基础上做了简化(去掉了论文中很多不必要的前缀包装),还针对训练稳定性做了调整。为了避免陷入梯度消失或梯度爆炸,我们不会从零初始化一个超深大模型,而是选择小参数规模(d_model=128,4层,4个注意力头),确保在CPU上也能正常训练。
用一张表说明本项目的参数规模:
| 参数名 | 取值 | 说明 |
|---|---|---|
| d_model | 128 | 词嵌入和模型核心维度 |
| n_layers | 4 | Encoder和Decoder各自的层数 |
| n_heads | 4 | 多头注意力头数 |
| d_ff | 512 | 前馈网络隐藏层维度 |
| dropout | 0.1 | 随机失活比例,用于防止过拟合 |
| batch_size | 32 | 每个批次训练样本数 |
| max_len | 50 | 句子的最大长度,超过截断 |
之所以选这么小的规模,是因为手撕模型的第一目标是“跑通”,而不是“屠榜”。把模型做大,只需要改几个参数;但把模型从0到1搭出来,是整个流程中最难的环节。
2. 核心细节解析与实操要点
2.1 自注意力机制:理解Q、K、V和缩放点积
Transformer最核心的组件莫过于自注意力(Self-Attention)了。如果把这个复杂概念用一句话说透,那就是:让序列里的每一个词,都去看一眼序列里所有其他词,然后根据“相关性”高低,有侧重地把它们的信息聚合过来。
以句子“我喜欢吃苹果”为例,当模型编码“苹果”这个词时,通过注意力机制,它可能会让“我”和“喜欢”贡献更多信息,从而知道“这是一个被人喜欢的水果/电子产品”。这个机制的本质是一个加权求和。
具体数学实现分为三步:
- 每个词通过三个不同的权重矩阵映射为
Q(Query,查询向量)、K(Key,键向量)、V(Value,值向量)。 - 计算Q和所有K的点积。点积越大,说明两个词的匹配度越高。
- 对点积结果进行缩放,并经过Softmax归一化为权重(所有权重和为1),然后对所有词的V求加权和。
这里面第一个坑是为什么要除以 √d_k(缩放因子)。设想一下,当维度很大时,点积的数值会很大,导致Softmax进入饱和区,梯度极小。除以根号d_k相当于把点积的方差拉回1,防止梯度过早消失。
第二个坑是Mask,在计算注意力分数的时候必须做两件事:
- Padding Mask:把 填充位置对应的注意力分数设为一个非常大的负数(比如
-1e9),这样Softmax之后对应权重概率极低,避免无意义的填充符干扰计算。 - Look-ahead Mask(因果掩码):只用于Decoder。因为Decoder在预测第t个词时,绝不能看到第t+1个词及以后的信息,否则就是“作弊”。实现上,我们构造一个下三角矩阵,把未来位置的分数也设为负无穷。
这两个Mask缺一不可。我最初就是因为只做了Padding Mask,导致训练时loss下降得飞快,但推理时输出烂成一团——因为模型训练时已经看到了“标准答案”。
下面是一段标了关键注释的核心代码:
def scaled_dot_product_attention(query, key, value, mask=None): # query, key, value: [batch_size, n_heads, seq_len, d_k] d_k = query.size(-1) scores = torch.matmul(query, key.transpose(-2, -1)) / math.sqrt(d_k) if mask is not None: scores = scores.masked_fill(mask == 0, -1e9) weights = torch.softmax(scores, dim=-1) return torch.matmul(weights, value), weights这段代码虽然只有五步,但我当初在里面折腾了两天,核心问题集中在mask的形状对齐上。所以,当你自己实现的时候,请务必像检查自己的银行余额一样,反复核对张量的shape。
2.2 多头注意力:让模型从多个角度“看”序列
多头注意力(Multi-Head Attention)不是复杂的东西,它其实就是把刚才的自注意力过程“复制”好几份,让每个头独立地做特征抽取,最后把结果拼接起来再做一次线性变换。
为什么要这样做?因为单一的自注意力只学习一种“相关性”模式,而多个头可以让模型关注不同的关系。比如在“小明打篮球,他很开心”这句话里,一个头可能关注“小明”和“他”的指代关系,另一个头可能关注“打”和“篮球”的动作关系。Google的论文里提到,不同头确实会学到不同类型的句法或语义特征。
实现的时候,常见做法有两种:
- 直接为每个头定义单独的线性层(Q、K、V各一份)。代码直观,但有冗余。
- 定义一个大线性层,输出维度为
n_heads * d_k,然后通过reshape拆分成多个头。代码更高效。
第二种是主流做法。在PyTorch里,view和transpose的组合足够完成拆分。这里有个非常隐蔽的坑:在计算注意力之前,要把head维度放到第二位去。记住标准的形状转化链:
输入:(batch, seq_len, d_model) -> 乘W后投影:(batch, seq_len, n_heads * d_k) -> view成:(batch, seq_len, n_heads, d_k) -> transpose成:(batch, n_heads, seq_len, d_k)
transpose之后,后续的矩阵乘法才能正确处理每个头独立的注意力计算。如果你用的是view而不是transpose来调换维度,数据在内存中的排列顺序会完全不对,虽然代码不报错,但结果必然错乱。这个“玄学bug”让我一度怀疑人生。
整个多头注意力的实现,建议你在自己动手的时候写两遍:第一遍老老实实按公式逐行写,第二遍再优化成高效版本。写两遍之后,这个模块你会记得非常牢。
2.3 位置编码:为什么RNN能记得顺序而Transformer不能
Transformer和RNN最大的区别在于:RNN天然可以顺序处理序列,而Transformer是并行计算的,它本身完全不知道词的先后顺序。如果我们把“我喜欢你”和“你喜欢我”两句话的词向量分别排列成两组相同的向量,Transformer会认为它们毫无区别。
位置编码(Positional Encoding)就是用来打破这种“顺序盲区”的。论文中使用了一组正弦和余弦函数来为每个位置生成一个唯一的向量:
pe = torch.zeros(max_len, d_model) position = torch.arange(0, max_len).unsqueeze(1) div_term = torch.exp(torch.arange(0, d_model, 2) * -(math.log(10000.0) / d_model)) pe[:, 0::2] = torch.sin(position * div_term) pe[:, 1::2] = torch.cos(position * div_term)用正弦函数的好处很多:
- 值域在 [-1, 1] 之间,稳定。
- 对任意位置,都可以生成确定的编码。
- 通过三角恒等式,模型可以学习到相对位置信息(比如位置i和位置i+k之间的某种线性表达)。
实操中,我把位置编码直接加在词嵌入的输出上。这里需要注意的一个细节是词嵌入的缩放:词嵌入的数值范围,通常需要乘以一个系数(例如√d_model)之后再与位置编码相加,否则位置编码会“淹没”词嵌入的信号。PyTorch官方文档中的原版代码往往省略了这一细节,我第一次照着敲时,模型在浅层阶段的loss表现异常,排查半天后才意识到是嵌入向量尺度不匹配的问题。
3. 实操过程与核心环节实现
3.1 构建词表与数据准备:微型中英翻译数据集
为了让整个项目快速跑起来,我没有使用大规模公开数据集,而是自己手工构造了一组微型中英平行语料。内容涵盖各种常见的“打招呼”、“自我介绍”、“询问天气”等简单句式。全部数据大约500条,按8:1:1划分训练集、验证集和测试集。
既然是翻译任务,我们就要构建两个词表:中文词表(Source Vocabulary)和英文词表(Target Vocabulary)。每个词表都包含四个特殊Token:
<PAD>:填充符,序号0。用于把同一批次中长度不一的句子补齐。<SOS>:序列开始符,序号1。Decoder在训练和推理时的起始输入。<EOS>:序列结束符,序号2。告诉模型句子输出到此为止。<UNK>:未知词,序号3。出现低频词时替代。
构建词表和编码的工作不难,直接写一个简易的Vocabulary类就搞定,顺带可以把分词做了——中文按字切分,英文按空格切分。这里我强烈建议你务必加上长度过滤,超过max_len的句子要么截断,要么直接丢弃,否则batch内部的矩阵运算会因为形状不一致直接崩溃。
3.2 从零搭建Transformer的Encoder:数据流过每一层
这一节是关键中的关键。我们把整个模型拆成几个子系统,逐个实现,最后像搭积木一样拼起来。一个标准的Encoder层包含以下结构,数据依次流过:
- 多头自注意力子层
- 残差连接 + LayerNorm
- 前馈全连接子层(Feed-Forward Network, FFN)
- 残差连接 + LayerNorm
这里需要解释两个核心概念:
残差连接(Residual Connection)是一种“捷径”,让原始输入和子层输出直接相加,从而在反向传播时,梯度可以通过这条路直接“抄近道”回到更浅层,有效避免深层网络的梯度消失问题。打个比方,这就像在拥挤的高速公路上修了一条“应急快速通道”,信号和梯度都可以更快地流通。
LayerNorm(层归一化)则是在每个样本的特征维度上进行归一化,让输出均值为0、方差为1。它和BatchNorm最大的区别是:LayerNorm不依赖批次大小,因此在序列长度可变、batch较小的时候更稳定。
实现时,我建议把EncoderLayer和DecoderLayer定义成两个类,然后通过nn.ModuleList将它们堆叠n_layers层。每一层的参数是独立的,不要试图共享权重。
上面说的是结构,但Python代码的组织顺序也很重要。如果只写一个巨大的forward函数,后面Debug的时候你会疯狂注释代码。我自己的习惯是子层拆分成独立方法或模块,这样每个环节都可以单独测试输出shape。
class EncoderLayer(nn.Module): def __init__(self, d_model, n_heads, d_ff, dropout=0.1): super().__init__() self.self_attn = MultiHeadAttention(d_model, n_heads, dropout) self.feed_forward = PositionwiseFeedForward(d_model, d_ff, dropout) self.norm1 = nn.LayerNorm(d_model) self.norm2 = nn.LayerNorm(d_model) self.dropout1 = nn.Dropout(dropout) self.dropout2 = nn.Dropout(dropout) def forward(self, x, mask=None): # 子层1:多头自注意力 + 残差 + 归一化 attn_output, _ = self.self_attn(x, x, x, mask) x = x + self.dropout1(attn_output) x = self.norm1(x) # 子层2:前馈网络 + 残差 + 归一化 ff_output = self.feed_forward(x) x = x + self.dropout2(ff_output) x = self.norm2(x) return x3.3 从零搭建Transformer的Decoder:训练与推理的两种模式
Decoder比Encoder复杂,因为它的forward必须处理两种截然不同的情况:训练模式和推理模式。
在训练模式下,我们有一个“标准答案句子”(Target Sentence)。为了让模型学会看完整的目标句,我们用**老师强制教学(Teacher Forcing)**的方式:把目标句整体喂给Decoder(每个词都作为输入),然后让模型预测下一个词。这种方式极大加快了收敛速度。
在推理模式下,没有标准答案。模型只能先输入<SOS>,预测出第一个词;然后把这个词拼接到输入末尾,再去预测第二个词……如此循环,直到模型输出<EOS>或者达到最大长度。这个过程叫自回归生成。
Decoder的内部结构如下:
- 第一层:Masked Multi-Head Self-Attention,只关注目标句自身的信息,且只能看到当前位置之前(防止作弊)。
- 第二层:Cross Multi-Head Attention,Query来自Decoder自身,而Key和Value来自Encoder的输出。这是Encoder和Decoder信息交互的关键通道。通俗地说,Encoder是“阅读理解”,Decoder是“写作文”,Cross Attention让Decoder在写每一句话时,都能“查询”到原文里相关的片段。
- 第三层:前馈网络。
- 每个子层后也同样跟随着残差连接和LayerNorm。
同样,我需要提醒一个经典错误:Encoder输出的Key和Value形状为(batch, seq_len_src, d_model),你需要在forward参数里显式把memory从Encoder传到Decoder。很多人把Decoder写成“闭门造车”,完全不看Encoder的输出,导致翻译结果Precision极差。
实现Decoder训练模式的代码逻辑如下:
class DecoderLayer(nn.Module): def __init__(self, d_model, n_heads, d_ff, dropout=0.1): super().__init__() self.self_attn = MultiHeadAttention(d_model, n_heads, dropout) self.cross_attn = MultiHeadAttention(d_model, n_heads, dropout) self.feed_forward = PositionwiseFeedForward(d_model, d_ff, dropout) # ...三个LayerNorm和Dropout def forward(self, x, memory, src_mask=None, tgt_mask=None): # 自注意力(Masked Self-Attention) attn_output, _ = self.self_attn(x, x, x, tgt_mask) x = x + self.dropout1(attn_output) x = self.norm1(x) # 交叉注意力(Cross Attention) attn_output, _ = self.cross_attn(x, memory, memory, src_mask) x = x + self.dropout2(attn_output) x = self.norm2(x) # 前馈网络 ff_output = self.feed_forward(x) x = x + self.dropout3(ff_output) x = self.norm3(x) return x3.4 训练循环的完整代码与参数计算细节
模型结构搭完,下一步就是让它动起来。我把训练循环拆成了几个逻辑清晰的模块,并在代码里对每个关键步骤做了注释。这里我分享核心的训练代码框架:
criterion = nn.CrossEntropyLoss(ignore_index=0) # 0是<PAD>的索引 optimizer = torch.optim.Adam(model.parameters(), lr=1e-3, betas=(0.9, 0.98), eps=1e-9) scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=10, gamma=0.5) for epoch in range(total_epochs): for src_batch, tgt_batch in train_loader: src_batch = src_batch.to(device) tgt_batch = tgt_batch.to(device) # shape: [batch_size, tgt_len] # 构建Tgt输入:去掉最后一个词,用于预测 tgt_input = tgt_batch[:, :-1] # 构建标准答案:去掉第一个词,用于计算损失 tgt_output = tgt_batch[:, 1:] # 构造Mask src_mask = create_padding_mask(src_batch) tgt_mask = create_look_ahead_mask(tgt_input) & create_padding_mask(tgt_input) # 前向传播 logits = model(src_batch, tgt_input, src_mask, tgt_mask) # logits: [batch_size, tgt_len, tgt_vocab_size] loss = criterion(logits.reshape(-1, tgt_vocab_size), tgt_output.reshape(-1)) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=0.5) optimizer.step()这段代码里有两个值得仔细琢磨的细节:
- 为什么输入和输出之间错位了一个“词”?假设标准答案是“我 爱 你”三个词。给模型输入
<SOS> 我 爱,让它预测我 爱 你。每个位置都对应一个预测目标,这正好模拟了“看到前一个词,预测后一个词”的过程。 - 为什么损失函数要
ignore_index=0?因为填充出来的<PAD>位置没有实际语义,计算损失时应当忽略这些位置。如果不做这个处理,模型会把大量注意力浪费在“预测出<PAD>”上,严重影响有效学习。
3.5 训练观察记录:loss从下降到崩溃的过程
我第一次跑通整个模型时,记录了一份代表性的loss曲线。它以时间线的方式清晰地展示了什么叫做“一鼓作气,再而衰,三而竭”。
- 第1 ~ 5个epoch:loss从最初的8.2快速下降到5.1。此时模型在疯狂从词表里学高频词,偶尔能蹦出几个“is”“I”之类的词,但翻译完全不成句。
- 第6 ~ 15个epoch:loss从5.1下降到2.8。由于数据集小,模型开始能记住部分训练集中的句子,在验证集上BLEU值稳步上升,但偶尔还会把中文里的“你好”翻译成“hello??”这种带未知标记的怪话。
- 第16 ~ 20个epoch:loss进一步下降到1.1。此时模型对训练集的过拟合开始显现,验证集loss不再下降或反而上扬。当loss降到0.8以下时,就要警惕过拟合,不要再盲目堆epoch了。
这里可以很明显看到几个实操结论:
- 学习率不能固定。前几步用高学习率快速收敛,后续逐渐降下来,可以用经典的Warmup + 衰减策略。我实现里直接偷了个懒,用
StepLR每10个epoch降一半。 - 梯度裁剪必不可少。尤其是作者在训练前几十步时,经常能看到梯度范数冲到几千甚至几万,不裁剪loss就会突然变成
nan。
4. 常见问题与排查技巧实录
4.1 模型输出全是重复词或单一词汇
这个现象非常典型,我第一次跑时就遇到了。模型在一段时间后反而开始退化,输出全是“the the the the”或者“。 ”。
排查的思路如下:
- 先查Decoder的Mask:你是不是在计算注意力分数时,把Mask错误地传递进去了?如果
tgt_mask形状是(batch, seq_len)而不是(batch, 1, seq_len, seq_len),那么masked_fill时会广播出错,等于没有Mask。 - 再查Softmax温度:模型输出的分布过于尖锐,可以尝试在推理时做温度采样(Temperature Sampling),把logits除以一个大于1的温度系数(如1.5),让概率分布更平滑一些,避免反复选中同一个高概率词。
重复词问题多源于训练不充分或Mask失效。建议你把tgt_mask打印出来,手动画一个5x5的矩阵,确认下三角区域全是True,上三角区域全是False。
4.2 训练时Loss出现NaN,模型直接“原地暴毙”
NaN问题是最让人头疼的,它出现的原因比较多,我按概率从高到低列几个:
- 学习率过高:loss很容易在第一步就变成NaN。把学习率从
1e-3降到5e-4或1e-4试试。 - 除零错误:在计算缩放点积注意力时,如果
d_k是0或某个计算步骤里出现了分母为0的操作,也会产生NaN。检查一下div_term在位置编码里的计算。 - 优化器状态:使用Adam时,beta2参数保持默认0.999即可,不要调太小,否则在训练初期梯度稀疏的情况下,二阶矩估计更新不稳。
- 打印关键张量:在loss变化异常时,立即在模型内部打印
attention weights、hidden_states是否存在NaN,缩小排查范围。
我个人的万能招式是:**先加载一个预训练好的小模型做比对。**看看是不是自己的数据、Mask流程整体有问题,再回来看是哪一层结构的问题。
4.3 损失函数在下降但翻译结果狗屁不通
这个坑最容易让人怀疑人生。一句话总结:loss下降只能说明模型学会了“最大化标准答案的概率”,但不代表它学会了“语义对应”。
回顾一下,可能的原因有:
- 数据集太小:几百条数据,模型完全可以“死记硬背”训练集,泛化能力极差。你需要增大数据量或者使用更平衡的数据分布。
- 词表切分问题:中文按字切分还是按词切分,对结果影响极大。对于小数据集,按字切分更稳,因为它能大幅降低词表稀疏性;按词切分则容易产生一堆低频词变成
<UNK>。 - Beam Search缺失:我上面的代码是贪心解码(每次选概率最大的词)。贪心解码容易犯“短视错误”,例如先选出一个高分词,但后续概率极低。而Beam Search(束搜索)会保留概率最高的K个候选(比如K=3),最后综合评估选最佳序列。手撕完基础Transformer后,给推理过程加上Beam Search,翻译质量会有质的提升。
4.4 推理速度极慢怎么办
如果你训练完毕,用模型翻译一句话需要好几秒,大概率是在推理循环中把整个Encoder重复了一遍。
正确做法是:在推理开始时,Encoder只前向计算一次,得到memory并缓存。之后Decoder每生成一个词,仅更新当前的query和key/value,而不是把整个序列从头到尾重新算一遍。这个优化叫KV Cache,是几乎所有大规模Transformer推理优化的基础。
我最初偷懒没有实现KV Cache,测试时发现每次生成一个词都要重新走一遍整个Decoder,时间复杂度直接翻了几倍。做完KV Cache之后,推理延迟至少下降了50%。
5. 项目扩展与实战建议
5.1 进阶方向一:从翻译到语言模型
我们这个项目训练的是一个**序列到序列(Seq2Seq)**模型,结构上天然适合翻译、摘要、对话等任务。但如果把Decoder单独拿出来,忽略Encoder部分,只让它学习“根据上文预测下一个词”,它就是一个标准的前置语言模型。这正是GPT系列的基础架构。
到了这一步,你就可以试着改造它:
- 去掉Encoder和Cross Attention。
- 在Decoder内部只保留Masked Self-Attention。
- 用大规模文本语料训练。
你会得到一个朴素但有效的“小型GPT”,之后再往上叠加更复杂的优化策略(如RoPE位置编码、SwiGLU激活函数、RMSNorm)时,才能有一份扎实的基座认知。
5.2 进阶方向二:上手工业级框架
当你手撕过Transformer后,我对你的下一步建议是:去读真实工业级框架的源码。这个顺序极为重要——先手撕造轮子,再读源码,你会事半功倍。
如果你已经能在一张中低端显卡上把本项目的模型跑通,再去读HuggingFace上某个知名预训练模型的源码,你会惊讶地发现:里面的核心模块名称虽然各不相同,但内在的计算逻辑却和你手写的结构有着几乎一一对应的关系。到那时,你手里拥有的就不再是一堆API参数,而是一个能洞察全局的“透视镜”。
写在最后的体会
手撕Transformer是一次极其磨炼心性的过程。我至今记得第一次跑通时那个平平无奇的晚上,屏幕上是依然充满错误翻译的测试句子,那一刻我却笑得非常开心。因为我知道,从那一刻起,“Attention Is All You Need”这句话对我来说不再是一个读不懂的标题,而是变成了一段段运行在内存中的张量流转、一次次Softmax的权重分配、一个个在Debug中被渐渐理顺的shape。
如果你跟着这篇文章一路写下来,在经历无数个报错、NaN、Loss倔强不降的夜晚之后,最终跑通了自己的Transformer。恭喜你,这绝不仅仅意味着你会写一个模型,更意味着你真正打开了现代深度学习的大门。前路还有无数模型等着你去“手撕”,但这一次,你已经拿到了自己的“武器”。
留一个小的实践作业给你:把我们这个微型数据集的规模从500条扩到10000条,然后观察loss曲线和你翻译结果的显著变化。只有亲手把规模scale up,你才会对深度学习中的“规模效应”有切身体会。祝你在手撕Transformer的路上,越撕越顺。