
🔥承渊政道: 个人主页
❄️个人专栏: 《C语言基础语法知识》 《数据结构与算法》 《C++知识内容》 《Linux系统知识》 《算法刷题指南》 《测评文章活动推广》 《大模型语言路线学习》 《MySQL数据库学习》 《Python知识内容》
✨逆境不吐心中苦,顺境不忘来时路!✨ 🎬 博主简介:

本文从1开始.前面介绍了编码器的架构和实现代码.那么相信你对编码器的编写已经很熟悉了.解码器是在编码器的基础上对模型进行少量修正,在不改变整体架构的基础上进行模型设计,可以说,如果掌握了编码器的原理,那么学习解码器的概念、设计和原理一定易如反掌.本文首先介绍解码器的原理和程序编写,然后着重解决一个非常大的问题------文本对齐.这是自然语言处理中一个不可轻易逾越的障碍,本文将以翻译模型为例,系统地讲解文本对齐的方法,并实现一个基于汉字和拼音的"翻译系统".


目录
- 1.解码器的核心---注意力模型
- 2.解码器实战---拼音汉字翻译模型
- 3.用现代PyTorch统一实现核心模块
-
- 3.1掩码函数
- 3.2位置编码和解码层
- [3.3拼音---汉字Seq2Seq 模型](#3.3拼音—汉字Seq2Seq 模型)
- 3.4训练时的移位与损失
- 3.5最小贪心解码
- 4.训练与评估:从"能跑"到"可信"
- 5.工程实践中的进一步补充
- 6.总结
1.解码器的核心---注意力模型
解码器在深度学习模型中具有非常重要的作用,即对传送过来的数据进行解码,生成具有特定格式的、内容可被理解的模型组件.解码器的结构如下图所示.

解码器的结构示意图
解码器的架构总体上与编码器类似,但还是有一部分区别,下面进行说明.
- 相对于编码器的单一输入(无论是叠加还是单独的词向量Embedding),解码器的输入有两部分,分别是编码器的输入和目标的Embedding输入.
- 相对于编码器中的多头注意力模型,解码器中的多头注意力模型分成两种,分别是多头自注意力层和多头交互注意力层.
总而言之,相对于编码器中的"单一模块",解码器中更多的是"双模块",即需要编码器的输入和解码器本身的输入协同处理.下面对这些内容进行详细介绍.
1.1解码器的输入和交互注意力层的掩码
如果换一种编码器和解码器的表示方法,如下图所示,可以清楚地看到,经过多层编码器的输出被输入多层解码器中.但是需要注意的是,编码器的输出对于解码器来说并不是直接使用,而是解码器本身先进行一次自注意力编码.下面就这两部分进一步说明.

编码器和解码器的表示方法
1.解码器的词嵌入输入
与编码器的词嵌入输入方式一样,解码器本身的词嵌入处理也是由初始化的词向量和位置向量构成的,结构如下图所示.

词嵌入处理
2.解码器的自注意力层(重点学习掩码的构建)
解码器的自注意力层是对输入的词嵌入进行编码的部分,这里的构造与编码器中的构造相同,不再过多阐述.
相对于编码器的掩码部分,解码器的掩码操作有其特殊的要求.
事实上,解码器的输入和编码器在处理上不太一样,一般认为编码器的输入是一个完整的序列,而解码器在训练和数据的生成过程中是逐个进行Token的生成的.因此,为了防止"偷看",解码器的自注意力层只能够关注输入序列当前位置以及之前的字,不能够关注之后的字.因此,需要将当前输入的字符Token之后的内容都进行掩码(mask)处理,使其在经过Softmax计算之后的权重变为0,不参与后续模型损失函数的计算,从而强制使得模型仅仅依靠之前输入的序列内容生成后续的"下一个字符序号".代码如下:
python
def create_look_ahead_mask(size):
mask = 1 - tf.linalg.band_part(tf.ones((size, size)), -1, 0)
return mask
如果单独打印代码如下:
python
mask = create_look_ahead_mask(4)
print(mask)
这里的参数size设置成4,则打印结果如下图所示.

可以看到,函数的实际作用是生成一个三角掩码,对输入的值生成逐行递增的梯度序列,这样可以保证数据在输入模型的过程中,数据的接收也是依次增加的,当前的Token只与其本身和其面的Token进行注意力计算,而不会与后续的Token进行注意力计算.这段内容的图形化效果如下图所示.

三角掩码器
此外,对于解码器自注意力层的输入,即Query、Key、Value的定义和设定,在解码器的自注意力层的输入都是由叠加后的词嵌入输入的,因此与编码器类似,可以将其设置成同一个.
3.解码器和编码器的交互注意力层(重点学习Query、Key和Value的定义)
编码器和解码器处理后的数据需要"交融",从而进行新的数据整合和生成,而进行数据整合和生
成的架构和模块在本例中所处的位置是交互注意力层.
编码器中的交互注意力层的架构和同处于编码器中的自注意力层没有太大的差别,其差距主要是输入的不同以及使用掩码对数据的处理不同.下面分别进行阐述.
(1)交互注意力层
交互注意力层的作用是将编码器输送的"全部"词嵌入与解码器获取的"当前"的词嵌入进行"融
合"计算,使得当前的词嵌入"对齐"编码器中对应的信息,从而获取解码后的信息.下面从解码器的角度进行讲解,如下图所示.

解码器
从图可以看到,对于"交互注意力"的输入,从编码器中输入的是两个,而解码器自注意力层中
输入的是一个,大家可能会有疑问,对于注意力层的Query、Key和Value,到底是如何安排和处理的?问题的解答还是要回归注意力层的定义:
attention ( ( K , V ) , q ) = ∑ i = 1 N a i v i = ∑ i = 1 N exp ( s ( k i , q ) ) ∑ j exp ( s ( k j , q ) ) v i \operatorname{attention}((K,V),q)= \sum_{i=1}^{N} a_i v_i= \sum_{i=1}^{N} \frac{\exp\left(s(k_i,q)\right)} {\sum_j \exp\left(s(k_j,q)\right)} v_i attention((K,V),q)=i=1∑Naivi=i=1∑N∑jexp(s(kj,q))exp(s(ki,q))vi
实际上,就是使用Query首先计算与Key的权重,之后使用权重与Value携带的信息进行比较,从而将Value中的信息"融合"到Query中.可以非常简单地得到,在交互注意力层中,解码器的自注意力词嵌入首先与编码器的输入词嵌入计算权重,然后使用计算出来的权重来计算编码器中的信息.即:
bash
query = 解码器词输入向量
key = 编码器词输出向量
value = 编码器词输出向量
(2)交互注意力中的掩码层(对谁进行掩码处理)
下面处理的是解码器中多头注意力的掩码层,相对于单一的自注意力层来说,一个非常显著的问题是对谁进行掩码处理.对这个问题的解答需要重新回到注意力模型的定义:
z i = Softmax ( scores ) v z_i = \operatorname{Softmax}(\text{scores})\,v zi=Softmax(scores)v
从权重的计算来看,解码器的词嵌入(Query)与编码器输入词嵌入(Key和Value)进行权重计算,从而将Query的值与Key和Value进行"融合".基于这点考虑,选择对编码器输入的词嵌入进行掩码处理.
如果对此不理解,现在请记住:
python
mask the encoder input embedding (对解码器中的编码器输出向量进行掩码操作)
有兴趣的可以自行查阅更多的资料进行了解.
下面两个函数分别展示普通掩码处理和在解码器中自注意力层掩码的写法:
python
#创建解码器中的交互注意力掩码
def creat_self_mask(from_tensor, to_tensor):
"""
这里需要注意,from_tensor 是输入的文本序列,即 input_word_ids ,
应该是2D的,即[1,2,3,4,5,6,0,0,0,0]
to_tensor 是输入的 input_word_ids,应该是2D的,即[1,2,3,4,5,6,0,0,0,0]
而经过本函数的扩充维度操作后,最终是输出两个3D的相乘后的结果
注意:后面如果需要4D的,则使用expand添加一个维度即可
"""
batch_size, from_seq_length = from_tensor.shape
to_mask = torch.not_equal(from_tensor, 0).int()
to_mask = elt.Rearrange("b l -> b 1 l")(to_mask) # 这里扩充了数据维度
broadcast_ones = torch.ones_like(to_tensor)
broadcast_ones = torch.unsqueeze(broadcast_ones, dim=-1)
mask = broadcast_ones * to_mask
mask.to("cuda")
return mask
打印结果和演示请自行完成.
然而,如果需要进一步提高准确率的话,还需要对掩码进行处理:
python
def create_look_ahead_mask(from_tensor, to_tensor):
corss_mask = creat_self_mask(from_tensor, to_tensor)
look_ahead_mask = torch.tril(torch.ones(to_tensor.shape[1], from_tensor.shape[1]))
look_ahead_mask = look_ahead_mask.to("cuda")
corss_mask = look_ahead_mask * corss_mask
return corss_mask
下面的代码段合成了pad_mask和look_ahead_mask,并通过maximum函数建立与或门,将其合成为一体,即:
python
tf.Tensor(
[[[[1. 0. 0. 0.]]]
[[[1. 1. 0. 0.]]]
[[[1. 1. 1. 0.]]]
[[[1. 1. 1. 1.]]]], shape=(4, 1, 1, 4), dtype=float32)
+
tf.Tensor(
[[0. 1. 1. 1.]
[0. 0. 1. 1.]
[0. 0. 0. 1.]
[0. 0. 0. 0.]], shape=(4, 4), dtype=float32)
=
tf.Tensor(
[[[[1. 1. 1. 1.]
[1. 0. 1. 1.]
[1. 0. 0. 1.]
[1. 0. 0. 0.]]]
[[[1. 1. 1. 1.]
[1. 1. 1. 1.]
[1. 1. 0. 1.]
[1. 1. 0. 0.]]]
[[[1. 1. 1. 1.]
[1. 1. 1. 1.]
[1. 1. 1. 1.]
[1. 1. 1. 0.]]]
[[[1. 1. 1. 1.]
[1. 1. 1. 1.]
[1. 1. 1. 1.]
[1. 1. 1. 1.]]]], shape=(4, 1, 4, 4), dtype=float32)
这样的处理可以最大限度地对无用部分进行掩码操作,从而使得解码器的输入(Query)与编码器的输入(Key, Value)能够最大限度地融合在一起,减少干扰.
1.2为什么通过掩码操作能够减少干扰
为什么在注意力层中,通过掩码操作能够减少干扰?这是由于Query和Value在进行点积计算时会产生大量的负值,而负值在进行Softmax计算时,由于Softmax的计算特性,会对平衡产生影响,代码如下.
python
class ScaledDotProductAttention(nn.Module):
def __init__(self):
super(ScaledDotProductAttention, self).__init__()
def forward(self, Q, K, V, attn_mask):
'''
Q: [batch_size, n_heads, len_q, d_k]
K: [batch_size, n_heads, len_k, d_k]
V: [batch_size, n_heads, len_v(=len_k), d_v]
attn_mask: [batch_size, n_heads, seq_len, seq_len]
'''
scores = torch.matmul(Q, K.transpose(-1, -2)) / np.sqrt(d_k)
# scores : [batch_size, n_heads, len_q, len_k]
scores.masked_fill_(attn_mask == 0, -1e9)
# attn_mask所有为True的部分(可被掩码操作的部分),
# scores填充为负无穷,也就是这个位置的值对于Softmax没有影响
attn = nn.Softmax(dim=-1)(scores)
# attn : [batch_size, n_heads, len_q, len_k]
# 对每一行进行Softmax
context = torch.matmul(attn, V)
# [batch_size, n_heads, len_q, d_v]
return context, attn
结果如图所示.

实际上是不需要这些负值的,因此需要在计算时加上一个"负无穷"的值降低负值对Softmax计算的影响(一般使用-1e5即可).
1.3因果掩码:防止模型在训练时偷看答案
缩放点积注意力为:
Attention ( Q , K , V ) = Softmax ( Q K ⊤ d k + M ) V \operatorname{Attention}(Q,K,V) =\operatorname{Softmax}\left(\frac{QK^\top}{\sqrt{d_k}}+M\right)V Attention(Q,K,V)=Softmax(dk QK⊤+M)V
对于长度为 T T T 的目标序列,因果掩码 M M M 定义为:
M i j = { 0 , j ≤ i − ∞ , j > i M_{ij}= \begin{cases} 0,&j\le i\\ -\infty,&j>i \end{cases} Mij={0,−∞,j≤ij>i
也就是说,第 i 个 Query 只能关注第 0...i 个 Key。未来位置的 logit 加上 -∞ 后,Softmax 权重变为 0。

1.4解码器的输出(移位训练方法)
前面介绍了解码器的一些基本操作,本小节将主要介绍解码器在最终阶段解码的变化和一些相关的细节,如图所示.

解码器的输出
解码器通过交互注意力的计算选择将当前的解码器词嵌入关注到编码器词嵌入中,选择生成一个新的词嵌入.
这是整体的步骤,当程序开始启动时,首先将编码器中的词嵌入全部输入,解码器首先接收一个起始符号的词嵌入,从而生成第一个解码的结果.
这种输入和输出错位的训练方法是"移位训练"方法.
接下来重复这个过程,每个步骤的输出在下一个时间步被提供给底端解码器,并且就像编码器之前做的那样,这些解码器会输出它们的解码结果.直到到达一个特殊的终止符号,它表示编码器-解码器架构已经完成了它的输出.
还有一点需要补充,解码器栈输出一个词嵌入,那如何将其变成一个输出词呢?这是最后一个全连接层的工作,并使用Softmax对输出进行归类计算.
全连接层是一个简单的全连接神经网络,它将解码器栈产生的向量投影到另一个向量维度,维度的大小对应生成字库的个数.之后的Softmax层将维度数值转换为概率.选择概率最大的维度,并对应地生成与之关联的字或者词作为此时间步的输出.
之后的Softmax层将这些分数转换为概率.选择概率最大的维度,并对应地生成与之关联的字或者词作为此时间步的输出.
1.5移位训练:为什么训练能并行,推理却要循环?
用"错位输入"解释训练过程.现代教材通常把它称为 shifted targets 或 teacher forcing.
若目标序列为:
text
<BOS> 你 好 吗 <EOS>
训练时拆成:
text
decoder_input = <BOS> 你 好 吗
labels = 你 好 吗 <EOS>

训练阶段
模型一次处理全部目标位置,并为每个位置预测下一个 Token.之所以不会泄漏答案,是因为因果掩码阻止位置 t 访问 t+1...T.
训练目标为 Token 级负对数似然:
L = − ∑ t = 1 T log p θ ( y t ∣ y < t , x ) \mathcal L=-\sum_{t=1}^{T}\log p_\theta(y_t\mid y_{<t},x) L=−t=1∑Tlogpθ(yt∣y<t,x)
推理阶段
推理时没有真实目标前缀,必须:
- 以
<BOS>开始 - 预测下一个 Token
- 把预测 Token 追加到目标前缀
- 遇到
<EOS>或达到最大长度时停止
使用 for 循环持续输入预测结果,概念上是正确的.现代部署还会加入 KV Cache:缓存历史自注意力的 Key/Value,以及固定编码器 Memory 的投影结果,避免每一步重复计算全部前缀.
Teacher Forcing的隐患:暴露偏差
训练时模型总能看到真实历史 Token,推理时却只能看到自己的预测.一旦某一步预测错误,后续状态可能不断偏移,这被称为 exposure bias.
常见缓解方法包括:
- 更强、更干净的数据和正则化
- beam search 或受约束解码
- scheduled sampling(需谨慎使用)
- 序列级目标、最小风险训练或强化学习方法
- 针对实际推理分布进行微调和评测
1.6解码器的实现
本小节介绍解码器的实现.首先,多注意力层实际上是通用的,代码如下.
python
class MultiHeadAttention(tf.keras.layers.Layer):
def __init__(self):
super(MultiHeadAttention, self).__init__()
def build(self, input_shape):
self.dense_query = tf.keras.layers.Dense(
units=embedding_size,
activation=tf.nn.relu
)
self.dense_key = tf.keras.layers.Dense(
units=embedding_size,
activation=tf.nn.relu
)
self.dense_value = tf.keras.layers.Dense(
units=embedding_size,
activation=tf.nn.relu
)
self.dense = tf.keras.layers.Dense(
units=embedding_size,
activation=tf.nn.relu
)
super(MultiHeadAttention, self).build(input_shape) # 一定要在最后调用它
def call(self, inputs):
query, key, value, mask = inputs
shape = tf.shape(query)
query_dense = self.dense_query(query)
key_dense = self.dense_query(key)
value_dense = self.dense_query(value)
query_dense = splite_tensor(query_dense)
key_dense = splite_tensor(key_dense)
value_dense = splite_tensor(value_dense)
attention = tf.matmul(
query_dense,
key_dense,
transpose_b=True
) / tf.math.sqrt(tf.cast(embedding_size, tf.float32))
attention += (mask * -1e9)
attention = tf.nn.softmax(attention)
attention = tf.matmul(attention, value_dense)
attention = tf.transpose(attention, [0, 2, 1, 3])
attention = tf.reshape(
attention,
[shape[0], -1, embedding_size]
)
attention = self.dense(attention)
return attention
其次,前馈层也可以通用,代码如下.
python
class FeedForWard(tf.keras.layers.Layer):
def __init__(self):
super(FeedForWard, self).__init__()
def build(self, input_shape):
self.conv_1 = tf.keras.layers.Conv1D(embedding_size*4,1,activation=tf.nn.relu)
self.conv_2 = tf.keras.layers.Conv1D(embedding_size,1,activation=tf.nn.relu)
super(FeedForWard, self).build(input_shape) # 一定要在最后调用它
def call(self, inputs):
output = self.conv_1(inputs)
output = self.conv_2(output)
return output
综合利用多层注意力层和前馈层,实现了专用的解码器的程序设计,代码如下.
python
class DecoderLayer(nn.Module):
def __init__(self):
super(DecoderLayer, self).__init__()
self.dec_self_attn = MultiHeadAttention()
self.dec_enc_attn = MultiHeadAttention()
self.pos_ffn = PoswiseFeedForwardNet()
def forward(self, dec_inputs, enc_outputs, dec_self_attn_mask, dec_enc_attn_mask):
'''
dec_inputs: [batch_size, tgt_len, d_model]
enc_outputs: [batch_size, src_len, d_model]
dec_self_attn_mask: [batch_size, tgt_len, tgt_len]
dec_enc_attn_mask: [batch_size, tgt_len, src_len]
'''
# dec_outputs: [batch_size, tgt_len, d_model],
# dec_self_attn: [batch_size, n_heads, tgt_len, tgt_len]
dec_outputs, dec_self_attn = self.dec_self_attn(
dec_inputs,
dec_inputs,
dec_inputs,
dec_self_attn_mask
)
# dec_outputs: [batch_size, tgt_len, d_model],
# dec_enc_attn: [batch_size, n_heads, tgt_len, src_len]
dec_outputs, dec_enc_attn = self.dec_enc_attn(
dec_outputs,
enc_outputs,
enc_outputs,
dec_enc_attn_mask
)
# encoder-decoder attention部分
dec_outputs = self.pos_ffn(dec_outputs) # [batch_size, tgt_len, d_model]
# 特征提取
return dec_outputs, dec_self_attn, dec_enc_attn
2.解码器实战---拼音汉字翻译模型
经过前面的学习,本节进入解码器实战------拼音汉字翻译模型.前面带领大家学习了注意力模型、前馈层以及掩码相关知识.这3部分内容共同构成了编码器-解码器架构的主要内容,共同组成的就是transformer这个基本架构,如图所示.

解码器
本节带领读者利用前面学习的知识完成一个翻译系统.不过在开始之前,有以下两个问题留给读者:
(1)编码器-解码器的翻译模型与编码器的转换模型有什么区别?
(2)如果想做汉字→拼音的翻译系统,编码器和解码器的输入端分别输入什么内容?
2.1数据集的获取与处理
首先是数据集的准备和处理,准备了15万条汉字和拼音对应数据.
1.数据集展示
本小节用于实战的汉字拼音数据集如下:

下面简单介绍一下.数据集中的数据分成3部分,每部分使用特定的空格键隔开.
- 第一部分A11_i为序号,表示序列的条数和行号.
- 第二部分是拼音编号,这里使用的是汉语拼音,与真实的拼音标注不同的是,去除了拼音原始标注,而使用数字1、2、3、4替代,分别代表当前读音的第一声到第四声,这点请注意.
- 最后一部分是汉字的序列,这里与第二部分的拼音部分一一对应.
2.获取字库和训练数据
获取数据集中字库的个数很重要,这里使用set格式的数据对全部字库中的不同字符进行读取.
创建字库和训练数据的完整代码如下.
python
import numpy as np
sentences = []
src_vocab = {'@': 0, '>': 1, '<': 2} # 这个是汉字vocab
tgt_vocab = {'@': 0, '>': 1, '<': 2} # 这个是拼音vocab
with open("../dataset/zh.tsv", errors="ignore", encoding="UTF-8") as f:
context = f.readlines()
for line in context:
line = line.strip().split(" ")
pinyin = line[1]
hanzi = line[2]
(hanzi_s) = hanzi.split(" ")
(pinyin_s) = pinyin.split(" ")
# [> <]
pinyin_inp = [">"] + pinyin_s
pinyin_trg = pinyin_s + ["<"]
line = [hanzi_s, pinyin_inp, pinyin_trg]
for char in hanzi_s:
if char not in src_vocab:
src_vocab[char] = len(src_vocab)
for char in pinyin_s:
if char not in tgt_vocab:
tgt_vocab[char] = len(tgt_vocab)
sentences.append(line)
这里做一个说明,首先context读取了全部数据集中的内容,之后根据空格将其分成3部分.对于拼音和汉字部分,将其转化成一个序列,并在前后分别加上起止符GO和终止符END.这实际上可以不用加,为了明确地描述起止关系,从而加上了起止标注.
实际上还需要加上一个特定符号PAD,这是为了对单行序列进行补全的操作,最终的数据如下:
python
['GO', 'liu2', 'yong3' , ... ... ... , 'gan1', ' END', 'PAD', 'PAD' , ... ... ...]
['GO', '柳', '永' , ... ... ... , '感', ' END', 'PAD', 'PAD' , ... ... ...]
pinyin_list和hanzi_list是两个列表,分别用来存放对应的拼音和汉字训练数据.最后不要忘记在字库中加上PAD符号.
python
pinyin_vocab = ["PAD"] + list(sorted(pinyin_vocab))
hanzi_vocab = ["PAD"] + list(sorted(hanzi_vocab))
3.根据字库生成Token数据
获取的拼音标注和汉字标注的训练数据并不能直接用于模型训练,模型需要转化成Token的一系列数字列表,代码如下:
python
enc_inputs, dec_inputs, dec_outputs = [], [], []
for line in sentences:
enc = line[0]
dec_in = line[1]
dec_tgt = line[2]
if len(enc) <= src_len and len(dec_in) <= tgt_len and len(dec_tgt) <= tgt_len:
enc_token = [src_vocab[char] for char in enc]
enc_token = enc_token + [0] * (src_len - len(enc_token))
dec_in_token = [tgt_vocab[char] for char in dec_in]
dec_in_token = dec_in_token + [0] * (tgt_len - len(dec_in_token))
dec_tgt_token = [tgt_vocab[char] for char in dec_tgt]
dec_tgt_token = dec_tgt_token + [0] * (tgt_len - len(dec_tgt_token))
enc_inputs.append(enc_token)
dec_inputs.append(dec_in_token)
dec_outputs.append(dec_tgt_token)
代码中创建了两个新的列表,分别对拼音和汉字的Token进行存储,从而获取根据字库序号编号后新的序列Token.
2.2翻译模型
翻译模型就是经典的编码器-解码器模型,整体代码如下.
python
# -*- coding: utf-8 -*-
import os
import math
import torch
import torch.nn as nn
import torch.optim as optim
import torch.utils.data as Data
from tqdm import tqdm
import get_dataset_v2
# =========================
# 1. 数据与词表
# =========================
sentences = get_dataset_v2.sentences
src_vocab = get_dataset_v2.src_vocab
tgt_vocab = get_dataset_v2.tgt_vocab
src_vocab_size = max(src_vocab.values()) + 1
tgt_vocab_size = max(tgt_vocab.values()) + 1
PAD_ID = 0
src_len = 48
tgt_len = 47
# =========================
# 2. Transformer 参数
# =========================
d_model = 512
d_ff = 2048
d_k = 64
d_v = 64
n_layers = 6
n_heads = 8
assert d_model == d_k * n_heads
assert d_model == d_v * n_heads
# =========================
# 3. 数据预处理
# =========================
enc_inputs = []
dec_inputs = []
dec_outputs = []
for line in sentences:
enc = line[0]
dec_in = line[1]
dec_tgt = line[2]
if (
len(enc) <= src_len
and len(dec_in) <= tgt_len
and len(dec_tgt) <= tgt_len
):
enc_token = [src_vocab[char] for char in enc]
enc_token += [PAD_ID] * (src_len - len(enc_token))
dec_in_token = [tgt_vocab[char] for char in dec_in]
dec_in_token += [PAD_ID] * (tgt_len - len(dec_in_token))
dec_tgt_token = [tgt_vocab[char] for char in dec_tgt]
dec_tgt_token += [PAD_ID] * (tgt_len - len(dec_tgt_token))
enc_inputs.append(enc_token)
dec_inputs.append(dec_in_token)
dec_outputs.append(dec_tgt_token)
enc_inputs = torch.LongTensor(enc_inputs)
dec_inputs = torch.LongTensor(dec_inputs)
dec_outputs = torch.LongTensor(dec_outputs)
print("enc_inputs:", enc_inputs.shape)
print("dec_inputs:", dec_inputs.shape)
print("dec_outputs:", dec_outputs.shape)
class MyDataSet(Data.Dataset):
def __init__(self, enc_inputs, dec_inputs, dec_outputs):
super().__init__()
self.enc_inputs = enc_inputs
self.dec_inputs = dec_inputs
self.dec_outputs = dec_outputs
def __len__(self):
return self.enc_inputs.shape[0]
def __getitem__(self, idx):
return (
self.enc_inputs[idx],
self.dec_inputs[idx],
self.dec_outputs[idx]
)
loader = Data.DataLoader(
MyDataSet(
enc_inputs,
dec_inputs,
dec_outputs
),
batch_size=512,
shuffle=True
)
# =========================
# 4. 位置编码
# =========================
class PositionalEncoding(nn.Module):
def __init__(
self,
d_model,
dropout=0.1,
max_len=5000
):
super().__init__()
self.dropout = nn.Dropout(
p=dropout
)
pe = torch.zeros(
max_len,
d_model
)
position = torch.arange(
0,
max_len,
dtype=torch.float
).unsqueeze(1)
div_term = torch.exp(
torch.arange(
0,
d_model,
2,
dtype=torch.float
)
*
(
-math.log(10000.0)
/
d_model
)
)
pe[:, 0::2] = torch.sin(
position * div_term
)
pe[:, 1::2] = torch.cos(
position * div_term
)
# [max_len, 1, d_model]
pe = pe.unsqueeze(1)
self.register_buffer(
"pe",
pe
)
def forward(self, x):
"""
x:
[seq_len, batch_size, d_model]
"""
x = x + self.pe[:x.size(0)]
return self.dropout(x)
# =========================
# 5. Mask
# =========================
def create_padding_mask(
query_ids,
key_ids
):
"""
query_ids:
[batch_size, len_q]
key_ids:
[batch_size, len_k]
return:
[batch_size, len_q, len_k]
True = 可以参与 Attention
False = 被屏蔽
"""
batch_size = query_ids.size(0)
len_q = query_ids.size(1)
len_k = key_ids.size(1)
# 屏蔽 Key 中的 padding
key_mask = key_ids.ne(
PAD_ID
).unsqueeze(1)
key_mask = key_mask.expand(
batch_size,
len_q,
len_k
)
return key_mask
def create_decoder_self_mask(
dec_inputs
):
"""
Decoder Self-Attention Mask
Padding Mask
+
Causal Mask
"""
padding_mask = create_padding_mask(
dec_inputs,
dec_inputs
)
seq_len = dec_inputs.size(1)
causal_mask = torch.tril(
torch.ones(
seq_len,
seq_len,
dtype=torch.bool,
device=dec_inputs.device
)
)
causal_mask = causal_mask.unsqueeze(0)
return padding_mask & causal_mask
# =========================
# 6. Scaled Dot Product Attention
# =========================
class ScaledDotProductAttention(nn.Module):
def __init__(self):
super().__init__()
def forward(
self,
Q,
K,
V,
attn_mask
):
"""
Q:
[batch_size, n_heads, len_q, d_k]
K:
[batch_size, n_heads, len_k, d_k]
V:
[batch_size, n_heads, len_k, d_v]
attn_mask:
[batch_size, 1, len_q, len_k]
"""
scores = torch.matmul(
Q,
K.transpose(-1, -2)
)
scores = scores / math.sqrt(
d_k
)
# mask=False 的位置不参与 softmax
scores = scores.masked_fill(
~attn_mask,
-1e9
)
attn = torch.softmax(
scores,
dim=-1
)
context = torch.matmul(
attn,
V
)
return context, attn
# =========================
# 7. Multi-Head Attention
# =========================
class MultiHeadAttention(nn.Module):
def __init__(self):
super().__init__()
self.W_Q = nn.Linear(
d_model,
d_k * n_heads,
bias=False
)
self.W_K = nn.Linear(
d_model,
d_k * n_heads,
bias=False
)
self.W_V = nn.Linear(
d_model,
d_v * n_heads,
bias=False
)
self.fc = nn.Linear(
n_heads * d_v,
d_model,
bias=False
)
self.layer_norm = nn.LayerNorm(
d_model
)
def forward(
self,
input_Q,
input_K,
input_V,
attn_mask
):
"""
input_Q:
[batch_size, len_q, d_model]
input_K:
[batch_size, len_k, d_model]
input_V:
[batch_size, len_k, d_model]
attn_mask:
[batch_size, len_q, len_k]
"""
residual = input_Q
batch_size = input_Q.size(0)
Q = self.W_Q(
input_Q
)
Q = Q.view(
batch_size,
-1,
n_heads,
d_k
)
Q = Q.transpose(
1,
2
)
K = self.W_K(
input_K
)
K = K.view(
batch_size,
-1,
n_heads,
d_k
)
K = K.transpose(
1,
2
)
V = self.W_V(
input_V
)
V = V.view(
batch_size,
-1,
n_heads,
d_v
)
V = V.transpose(
1,
2
)
# [B, len_q, len_k]
# ->
# [B, 1, len_q, len_k]
attn_mask = attn_mask.unsqueeze(
1
)
context, attn = (
ScaledDotProductAttention()(
Q,
K,
V,
attn_mask
)
)
context = context.transpose(
1,
2
).contiguous()
context = context.view(
batch_size,
-1,
n_heads * d_v
)
output = self.fc(
context
)
output = self.layer_norm(
output + residual
)
return output, attn
# =========================
# 8. Feed Forward
# =========================
class PoswiseFeedForwardNet(nn.Module):
def __init__(self):
super().__init__()
self.fc = nn.Sequential(
nn.Linear(
d_model,
d_ff,
bias=False
),
nn.ReLU(),
nn.Linear(
d_ff,
d_model,
bias=False
)
)
self.layer_norm = nn.LayerNorm(
d_model
)
def forward(
self,
inputs
):
"""
inputs:
[batch_size, seq_len, d_model]
"""
residual = inputs
output = self.fc(
inputs
)
output = self.layer_norm(
output + residual
)
return output
# =========================
# 9. Encoder Layer
# =========================
class EncoderLayer(nn.Module):
def __init__(self):
super().__init__()
self.enc_self_attn = (
MultiHeadAttention()
)
self.pos_ffn = (
PoswiseFeedForwardNet()
)
def forward(
self,
enc_inputs,
enc_self_attn_mask
):
enc_outputs, attn = (
self.enc_self_attn(
enc_inputs,
enc_inputs,
enc_inputs,
enc_self_attn_mask
)
)
enc_outputs = self.pos_ffn(
enc_outputs
)
return (
enc_outputs,
attn
)
# =========================
# 10. Decoder Layer
# =========================
class DecoderLayer(nn.Module):
def __init__(self):
super().__init__()
self.dec_self_attn = (
MultiHeadAttention()
)
self.dec_enc_attn = (
MultiHeadAttention()
)
self.pos_ffn = (
PoswiseFeedForwardNet()
)
def forward(
self,
dec_inputs,
enc_outputs,
dec_self_attn_mask,
dec_enc_attn_mask
):
# 1.
# Decoder Self-Attention
dec_outputs, dec_self_attn = (
self.dec_self_attn(
dec_inputs,
dec_inputs,
dec_inputs,
dec_self_attn_mask
)
)
# 2.
# Encoder-Decoder Attention
dec_outputs, dec_enc_attn = (
self.dec_enc_attn(
dec_outputs,
enc_outputs,
enc_outputs,
dec_enc_attn_mask
)
)
# 3.
# Feed Forward
dec_outputs = self.pos_ffn(
dec_outputs
)
return (
dec_outputs,
dec_self_attn,
dec_enc_attn
)
# =========================
# 11. Encoder
# =========================
class Encoder(nn.Module):
def __init__(self):
super().__init__()
self.src_emb = nn.Embedding(
src_vocab_size,
d_model,
padding_idx=PAD_ID
)
self.pos_emb = (
PositionalEncoding(
d_model
)
)
self.layers = nn.ModuleList(
[
EncoderLayer()
for _ in range(
n_layers
)
]
)
def forward(
self,
enc_inputs
):
# [B, src_len]
# ->
# [B, src_len, d_model]
enc_outputs = self.src_emb(
enc_inputs
)
# 添加位置编码
enc_outputs = self.pos_emb(
enc_outputs.transpose(
0,
1
)
).transpose(
0,
1
)
# Encoder Padding Mask
enc_self_attn_mask = (
create_padding_mask(
enc_inputs,
enc_inputs
)
)
enc_self_attns = []
for layer in self.layers:
(
enc_outputs,
enc_self_attn
) = layer(
enc_outputs,
enc_self_attn_mask
)
enc_self_attns.append(
enc_self_attn
)
return (
enc_outputs,
enc_self_attns
)
# =========================
# 12. Decoder
# =========================
class Decoder(nn.Module):
def __init__(self):
super().__init__()
self.tgt_emb = nn.Embedding(
tgt_vocab_size,
d_model,
padding_idx=PAD_ID
)
self.pos_emb = (
PositionalEncoding(
d_model
)
)
self.layers = nn.ModuleList(
[
DecoderLayer()
for _ in range(
n_layers
)
]
)
def forward(
self,
dec_inputs,
enc_inputs,
enc_outputs
):
# embedding
dec_outputs = self.tgt_emb(
dec_inputs
)
# positional encoding
dec_outputs = self.pos_emb(
dec_outputs.transpose(
0,
1
)
).transpose(
0,
1
)
# -------------------------
# Decoder Self Attention
# -------------------------
dec_self_attn_mask = (
create_decoder_self_mask(
dec_inputs
)
)
# -------------------------
# Encoder-Decoder Attention
# -------------------------
dec_enc_attn_mask = (
create_padding_mask(
dec_inputs,
enc_inputs
)
)
dec_self_attns = []
dec_enc_attns = []
for layer in self.layers:
(
dec_outputs,
dec_self_attn,
dec_enc_attn
) = layer(
dec_outputs,
enc_outputs,
dec_self_attn_mask,
dec_enc_attn_mask
)
dec_self_attns.append(
dec_self_attn
)
dec_enc_attns.append(
dec_enc_attn
)
return (
dec_outputs,
dec_self_attns,
dec_enc_attns
)
# =========================
# 13. Transformer
# =========================
class Transformer(nn.Module):
def __init__(self):
super().__init__()
self.encoder = Encoder()
self.decoder = Decoder()
self.projection = nn.Linear(
d_model,
tgt_vocab_size,
bias=False
)
def forward(
self,
enc_inputs,
dec_inputs
):
(
enc_outputs,
enc_self_attns
) = self.encoder(
enc_inputs
)
(
dec_outputs,
dec_self_attns,
dec_enc_attns
) = self.decoder(
dec_inputs,
enc_inputs,
enc_outputs
)
dec_logits = self.projection(
dec_outputs
)
return (
dec_logits.reshape(
-1,
dec_logits.size(-1)
),
enc_self_attns,
dec_self_attns,
dec_enc_attns
)
# =========================
# 14. 设备
# =========================
device = torch.device(
"cuda"
if torch.cuda.is_available()
else "cpu"
)
print(
"device:",
device
)
# =========================
# 15. 创建模型
# =========================
model = Transformer().to(
device
)
# =========================
# 16. 保存路径
# =========================
save_path = (
"./save/transformer.pt"
)
os.makedirs(
os.path.dirname(
save_path
),
exist_ok=True
)
# 如果已经训练过,
# 可以取消下面代码的注释
# if os.path.exists(save_path):
#
# model.load_state_dict(
#
# torch.load(
# save_path,
# map_location=device
# )
#
# )
# =========================
# 17. Loss 和优化器
# =========================
criterion = nn.CrossEntropyLoss(
ignore_index=PAD_ID
)
optimizer = optim.AdamW(
model.parameters(),
lr=2e-5
)
# =========================
# 18. 训练
# =========================
num_epochs = 1024
for epoch in range(
num_epochs
):
model.train()
pbar = tqdm(
loader,
total=len(loader)
)
for (
enc_batch,
dec_batch,
target_batch
) in pbar:
enc_batch = enc_batch.to(
device
)
dec_batch = dec_batch.to(
device
)
target_batch = target_batch.to(
device
)
(
outputs,
enc_self_attns,
dec_self_attns,
dec_enc_attns
) = model(
enc_batch,
dec_batch
)
loss = criterion(
outputs,
target_batch.reshape(
-1
)
)
optimizer.zero_grad()
loss.backward()
optimizer.step()
pbar.set_description(
f"epoch "
f"{epoch + 1}/"
f"{num_epochs} "
f"| loss "
f"{loss.item():.6f}"
)
torch.save(
model.state_dict(),
save_path
)
# =========================
# 19. 测试预测
# =========================
idx2word = {
idx: token
for token, idx
in tgt_vocab.items()
}
model.eval()
(
enc_batch,
dec_batch,
target_batch
) = next(
iter(loader)
)
with torch.no_grad():
(
predict,
e_attn,
d1_attn,
d2_attn
) = model(
enc_batch[0]
.unsqueeze(0)
.to(device),
dec_batch[0]
.unsqueeze(0)
.to(device)
)
predict_ids = (
predict
.argmax(dim=1)
.cpu()
.tolist()
)
predict_tokens = [
idx2word.get(
i,
"<UNK>"
)
for i in predict_ids
]
print(
"encoder input:",
enc_batch[0]
)
print(
"prediction:",
predict_tokens
)
以上代码就是transformer的结构代码,实际上就是综合前面所学的全部知识,结合编码器和解码器.可以使用以下程序对代码进行测试.
python
if __name__ == "__main__":
encoder_input = tf.keras.Input(shape=(None,))
decoder_input = tf.keras.Input(shape=(None,))
output = Transformer(1024,1024)([encoder_input,decoder_input])
model = tf.keras.Model((encoder_input,decoder_input),output)
print(model.summary())
打印结果请自行验证.
2.3拼音汉字模型的训练
本小节进行transformer的训练.需要注意的是,相对于前面的学习,transformer的训练过程需要特别注意编码器的输出和解码器输入的错位计算.
第1次输入:编码器输入完整的序列GOni hao maEND.与此同时,解码器的输入端输入的是解码开始符GO,经过交互计算后,解码器的输出为"你".
第2次输入:编码器输入完整的序列GOni hao maEND.与此同时,解码器的输入端输入的是解码开始符GO和字符"你",经过交互计算后,解码器的输出为"你好".
这样依次进行输出.然后依次进行错位输入.
最后一次输入:编码器输入的还是完整序列,此时在解码器的输出端会输出带有结束符的序列,表明解码结束.
第1次输入:
python
编码器输入:[GO]ni hao ma[END]
解码器输入:[GO]
解码器输出:你
第2次输入:
python
编码器输入:[GO]ni hao ma[END]
解码器输入:[GO]你
解码器输出:你 好
第3次输入:
python
编码器输入:[GO]ni hao ma[END]
解码器输入:[GO]你 好
解码器输出:你 好 吗
最后一次输入:
python
编码器输入:[GO]ni hao ma[END]
解码器输入:[GO]你 好 吗
解码器输出:你 好 吗 [END]
计算步骤如下图所示.

如编码器读取数据一样,由于硬件设备的原因,需要使用数据生成器循环生成数据,并且在生成器中进行错位输入.具体请自行完成.
2.4拼音汉字模型的使用
相信大家一定发现了,相对于拼音汉字转换模型,拼音汉字翻译模型并不是整体一次性输出的,而是根据在编码器中的输入内容生成特定的输出内容.
根据这个特性,如果想获取完整的解码器生成的数据内容,则需要采用循环输入的方式完成模型的使用,代码如下.
python
idx2pinyin = {i: w for i, w in enumerate(tgt_vocab)}
idx2hanzi = {i: w for i, w in enumerate(src_vocab)}
context = "你好吗"
token = [src_vocab[char] for char in context]
token = torch.tensor(token)
sentence_tensor = torch.LongTensor(token).unsqueeze(0).to(device)
outputs = [1]
for i in range(tgt_len):
trg_tensor = torch.LongTensor(outputs).unsqueeze(0).to(device)
with torch.no_grad():
output= model(sentence_tensor, trg_tensor)
best_guess = torch.argmax(output,dim=-1).detach().cpu()
outputs.append(best_guess[-1])
# if best_guess[-1] == 2:
# break
print([idx2pinyin[id.item()] for id in outputs[1:]])
以上代码演示了循环输出预测结果,这里使用了一个for循环对预测进行输入,具体请自行验证.
3.用现代PyTorch统一实现核心模块
PyTorch 风格的 nn.Module / torch.matmul 与 TensorFlow/Keras 风格的 tf.keras.layers.Layer.这会让初学者难以判断张量形状和掩码语义.下面给出一套独立、统一的 PyTorch 2.x 参考实现,重点展示正确的数据流.
3.1掩码函数
python
from __future__ import annotations
import torch
from torch import Tensor
def make_causal_mask(length: int, device: torch.device) -> Tensor:
"""返回 [T, T] 布尔掩码;True 表示该位置禁止被关注。"""
return torch.triu(
torch.ones(length, length, dtype=torch.bool, device=device),
diagonal=1,
)
def make_padding_mask(tokens: Tensor, pad_id: int) -> Tensor:
"""tokens: [B, T];返回 [B, T],True 表示 PAD Key。"""
return tokens.eq(pad_id)
快速自检:
python
mask = make_causal_mask(4, torch.device("cpu"))
assert mask.tolist() == [
[False, True, True, True],
[False, False, True, True],
[False, False, False, True],
[False, False, False, False],
]
3.2位置编码和解码层
python
import math
import torch
from torch import Tensor, nn
class PositionalEncoding(nn.Module):
def __init__(self, d_model: int, max_len: int = 2048) -> None:
super().__init__()
if d_model % 2 != 0:
raise ValueError("d_model 必须为偶数")
position = torch.arange(max_len).unsqueeze(1)
div_term = torch.exp(
torch.arange(0, d_model, 2) * (-math.log(10000.0) / d_model)
)
pe = torch.zeros(1, max_len, d_model)
pe[0, :, 0::2] = torch.sin(position * div_term)
pe[0, :, 1::2] = torch.cos(position * div_term)
self.register_buffer("pe", pe, persistent=False)
def forward(self, x: Tensor) -> Tensor:
return x + self.pe[:, : x.size(1)]
class DecoderLayer(nn.Module):
def __init__(
self,
d_model: int,
n_heads: int,
d_ff: int,
dropout: float = 0.1,
) -> None:
super().__init__()
self.self_attn = nn.MultiheadAttention(
d_model, n_heads, dropout=dropout, batch_first=True
)
self.cross_attn = nn.MultiheadAttention(
d_model, n_heads, dropout=dropout, batch_first=True
)
self.ffn = nn.Sequential(
nn.Linear(d_model, d_ff),
nn.GELU(),
nn.Dropout(dropout),
nn.Linear(d_ff, d_model),
)
self.norm1 = nn.LayerNorm(d_model)
self.norm2 = nn.LayerNorm(d_model)
self.norm3 = nn.LayerNorm(d_model)
self.drop1 = nn.Dropout(dropout)
self.drop2 = nn.Dropout(dropout)
self.drop3 = nn.Dropout(dropout)
def forward(
self,
x: Tensor,
memory: Tensor,
causal_mask: Tensor,
tgt_padding_mask: Tensor,
memory_padding_mask: Tensor,
) -> Tensor:
# 1) Q = K = V = x;同时应用 causal 与 target padding mask
z, _ = self.self_attn(
query=x,
key=x,
value=x,
attn_mask=causal_mask,
key_padding_mask=tgt_padding_mask,
need_weights=False,
)
x = self.norm1(x + self.drop1(z))
# 2) Q 来自 decoder;K/V 来自 encoder memory
z, _ = self.cross_attn(
query=x,
key=memory,
value=memory,
key_padding_mask=memory_padding_mask,
need_weights=False,
)
x = self.norm2(x + self.drop2(z))
# 3) 每个位置独立通过前馈网络
z = self.ffn(x)
return self.norm3(x + self.drop3(z))
这段代码采用经典 Post-Norm 结构,便于对应原始 Transformer.更深的网络常使用 Pre-Norm 提升优化稳定性,但不要在没有实验依据时随意混用两种规范化顺序.
3.3拼音---汉字Seq2Seq 模型
python
class PinyinToHanzi(nn.Module):
def __init__(
self,
src_vocab_size: int,
tgt_vocab_size: int,
src_pad_id: int,
tgt_pad_id: int,
d_model: int = 256,
n_heads: int = 8,
d_ff: int = 1024,
n_encoder_layers: int = 4,
n_decoder_layers: int = 4,
dropout: float = 0.1,
) -> None:
super().__init__()
self.src_pad_id = src_pad_id
self.tgt_pad_id = tgt_pad_id
self.d_model = d_model
self.src_embedding = nn.Embedding(
src_vocab_size, d_model, padding_idx=src_pad_id
)
self.tgt_embedding = nn.Embedding(
tgt_vocab_size, d_model, padding_idx=tgt_pad_id
)
self.position = PositionalEncoding(d_model)
self.embedding_dropout = nn.Dropout(dropout)
encoder_layer = nn.TransformerEncoderLayer(
d_model=d_model,
nhead=n_heads,
dim_feedforward=d_ff,
dropout=dropout,
activation="gelu",
batch_first=True,
norm_first=True,
)
self.encoder = nn.TransformerEncoder(
encoder_layer, num_layers=n_encoder_layers
)
self.decoder_layers = nn.ModuleList(
[
DecoderLayer(d_model, n_heads, d_ff, dropout)
for _ in range(n_decoder_layers)
]
)
self.final_norm = nn.LayerNorm(d_model)
self.output_projection = nn.Linear(d_model, tgt_vocab_size)
def forward(self, src_ids: Tensor, tgt_input_ids: Tensor) -> Tensor:
src_padding_mask = make_padding_mask(src_ids, self.src_pad_id)
tgt_padding_mask = make_padding_mask(tgt_input_ids, self.tgt_pad_id)
causal_mask = make_causal_mask(tgt_input_ids.size(1), tgt_input_ids.device)
src = self.src_embedding(src_ids) * math.sqrt(self.d_model)
src = self.embedding_dropout(self.position(src))
memory = self.encoder(
src,
src_key_padding_mask=src_padding_mask,
)
x = self.tgt_embedding(tgt_input_ids) * math.sqrt(self.d_model)
x = self.embedding_dropout(self.position(x))
for layer in self.decoder_layers:
x = layer(
x=x,
memory=memory,
causal_mask=causal_mask,
tgt_padding_mask=tgt_padding_mask,
memory_padding_mask=src_padding_mask,
)
return self.output_projection(self.final_norm(x))
3.4训练时的移位与损失
python
import torch.nn.functional as F
def training_loss(
model: PinyinToHanzi,
src_ids: Tensor,
full_tgt_ids: Tensor,
) -> Tensor:
# full_tgt_ids: [BOS, y1, y2, ..., EOS, PAD, ...]
tgt_input_ids = full_tgt_ids[:, :-1]
labels = full_tgt_ids[:, 1:]
logits = model(src_ids, tgt_input_ids) # [B, T-1, vocab]
return F.cross_entropy(
logits.reshape(-1, logits.size(-1)),
labels.reshape(-1),
ignore_index=model.tgt_pad_id,
label_smoothing=0.1,
)
这里有三个高频错误:
decoder_input与labels没有错开一位,模型学成复制当前 Token;- 未设置
ignore_index=PAD_ID,大量补齐位置主导损失; - 先对
logits做 Softmax 再传入cross_entropy,重复归一化并损害数值稳定性.
3.5最小贪心解码
python
@torch.inference_mode()
def greedy_decode(
model: PinyinToHanzi,
src_ids: Tensor,
bos_id: int,
eos_id: int,
max_new_tokens: int,
) -> Tensor:
model.eval()
batch_size = src_ids.size(0)
generated = torch.full(
(batch_size, 1),
bos_id,
dtype=torch.long,
device=src_ids.device,
)
finished = torch.zeros(batch_size, dtype=torch.bool, device=src_ids.device)
for _ in range(max_new_tokens):
logits = model(src_ids, generated)
next_token = logits[:, -1].argmax(dim=-1)
next_token = torch.where(
finished,
torch.full_like(next_token, model.tgt_pad_id),
next_token,
)
generated = torch.cat([generated, next_token[:, None]], dim=1)
finished |= next_token.eq(eos_id)
if finished.all():
break
return generated
为了突出原理,这个函数每一步都重新计算编码器和完整目标前缀.生产实现应把 memory 提前计算一次,并为解码器维护 KV Cache.
4.训练与评估:从"能跑"到"可信"
4.1推荐训练配置
对于教学规模模型,可以从以下配置起步,再通过验证集调参:
| 项目 | 建议起点 |
|---|---|
d_model |
256 或 512 |
| 注意力头数 | 4 或 8,且整除 d_model |
| 编码器/解码器层数 | 各 3--6 层 |
d_ff |
4 × d_model 左右 |
| Dropout | 0.1 |
| 优化器 | AdamW |
| 学习率策略 | warmup + cosine / inverse-square-root decay |
| 梯度裁剪 | max_norm=1.0 |
| 标签平滑 | 0.05--0.1,可从 0.1 起试 |
| 混合精度 | 支持 GPU 时优先 BF16,其次 FP16 |
应记录随机种子、词表、数据版本、规范化规则、模型配置和最佳 checkpoint.只保存权重文件不足以复现实验.
4.2指标不能只有平均准确率
建议至少报告:
- 非
<PAD>字符准确率 - 整句完全匹配率
- 字符级编辑距离或 Character Error Rate
- 按句长分桶的结果
- 多音字、低频字、未登录拼音组合的专项结果
- 推理延迟、吞吐量和最大显存
拼音转汉字存在一对多的不确定性.若参考答案只有一个,即使模型生成了语义合理的同音句,也可能被整句准确率判错.因此,高质量评估还应加入多参考答案或人工误差分析.
4.3先做过拟合小样本测试
正式训练前,取 32--128 条样本,确认模型能把训练损失降到很低并复现目标序列.这是发现以下错误最快的方法:
- 源/目标词表用反
<BOS>、<EOS>插入位置错误- 因果掩码方向反了
True/False掩码语义写反- padding 参与损失
- 推理未在
<EOS>停止
5.工程实践中的进一步补充
5.1解码策略
- Greedy Search:速度快、结果稳定,适合拼音转汉字的基础实验.
- Beam Search:保留多个候选前缀,常能提高结构化翻译质量,但需设置 beam size、长度惩罚和终止条件.
- Top-k / Top-p Sampling:适合开放式生成;对强调唯一正确答案的拼音转汉字通常不是首选.
5.2KV Cache
无缓存时,第 t 步会重新计算长度为 t 的整个目标前缀.KV Cache 保存每层历史 Key/Value,使新一步只计算新增 Token.对长序列和大模型,缓存是低延迟推理的关键.
5.3位置编码
原始 Transformer 使用固定正弦位置编码.现代模型还常见可学习位置向量、相对位置偏置、RoPE 和 ALiBi.教学时先掌握正弦位置编码;迁移到大模型时再理解相对位置和长度外推.
5.4权重共享
若目标端 Embedding 维度与输出投影兼容,可以共享 tgt_embedding.weight 与 output_projection.weight,减少参数量并可能改善学习.但源词表和目标词表不同,不应无条件共享两侧 Embedding.
5.5数值稳定性
- 混合精度下优先使用框架提供的布尔 mask 或安全的极小值
- 避免某个 Query 的全部 Key 都被屏蔽
- 监控 loss、梯度范数和
NaN/Inf - 使用
need_weights=False可让 PyTorch 在满足条件时走更高效的注意力实现.
6.总结
本文最重要的知识不是某个框架的具体 API,而是四条可迁移的原则:
- 信息边界:因果掩码决定目标端不能使用未来信息.
- 条件检索:交叉注意力让目标端以 Query 读取源端 Memory.
- 目标移位:训练输入与监督标签错开一位,形成下一个 Token 预测.
- 训练---推理解耦:训练可以并行,推理必须自回归,并可用缓存优化.
把这四条原则吃透,拼音转汉字只是一个具体案例;机器翻译、摘要、对话生成乃至 GPT 类大模型,都可以在同一套概率分解和注意力机制下理解.
🚀真正的勇者不是流泪的人,而是含泪奔跑的人!
敬请期待下一篇文章内容
每日心灵鸡汤: 每一次跃迁,都是一次重新排序!
人生每一次跃迁,本质上都是一次重新排序.当你的目标、认知和位置发生变化时,不适合继续同行的人,会自然离开.不是上天惩罚谁,也不是谁背叛了谁,而是旧的关系无法承载新的阶段.真正重要的不是失去谁,而是在变化之后,你是否成为一个能够承接下一阶段的人.
