承渊政道头像
关注
从零开始大模型开发与微调:基于PyTorch与ChatGLM】(从Q/K/V到拼音转汉字:用PyTorch从零拆解Transformer编码器)封面图

从零开始大模型开发与微调:基于PyTorch与ChatGLM】(从Q/K/V到拼音转汉字:用PyTorch从零拆解Transformer编码器)

🔥承渊政道:个人主页

❄️个人专栏: 《C语言基础语法知识》 《数据结构与算法》 《C++知识内容》 《Linux系统知识》 《算法刷题指南》 《测评文章活动推广》 《大模型语言路线学习》 《MySQL数据库学习》 《Python知识内容》

✨逆境不吐心中苦,顺境不忘来时路!✨
🎬 博主简介:

Transformer 编码器做的事情,可以压缩成一句话:**把一串离散 token,变成一串已经理解上下文的连续向量.**例如,“苹果"在"我买了一部苹果手机"和"我吃了一个苹果"中拥有相同的字面形式,却应得到不同的上下文表示.静态词向量只能告诉模型"苹果大概是什么”,自注意力编码器则会进一步回答"这个位置的苹果在当前句子里是什么意思".本文从输入嵌入和位置编码出发,逐步推导缩放点积注意力,解释 Q、K、V、多头机制、掩码、残差连接、Layer Normalization 与前馈网络,最后构造一个拼音转汉字的逐位置预测模型.读完后,你不仅能看懂编码器结构图,还能判断张量形状、写出核心 PyTorch 代码,并知道教学代码与工程实现之间的边界.本文将从一个简单的编码器开始,介绍其核心架构、整体框架及其实现,并以此为基础引入编程实战,即一个对汉字和拼音转换的翻译.但是编码器并不是简单地使用,其更重要的内容是在此基础上引入transform架构的基础概念,这是目前最为流行和通用的编码器架构,并在此基础上衍生出了更多的内容.



1.编码器的核心——注意力模型

编码器的作用是对输入的字符序列进行编码处理,从而获得特定的词向量结果.为了简便起见,直接使用transformer的编码器方案,这也是目前最为常用的编码器架构方案.编码器的结构如下图所示.

编码器结构示意图

从图中可见,编码器由以下多个模块构成:

  • 初始词向量(Input Embedding)层.
  • 位置编码器(Positional Encoding)层.
  • 多头自注意力(Multi-Head Attention)层.
  • 前馈(Feed Forward)层.

实际上,编码器的构成模块并不是固定的,也没有特定的形式,transformer的编码器架构是目前最为常用的,因此接下来将以此为例进行介绍.首先介绍编码器的核心内容:注意力模型和架构,然后以此为基础完成整个编码器的介绍和编写.

编码器到底在编码什么
自然语言模型不能直接计算"你"“好”"吗"这些符号.第一步通常是建立词表,把 token 映射为整数编号,再通过 nn.Embedding 把编号查表为向量:

原始文本:  你      好      吗
Token ID:  17     28      39
向量表示:  x₁     x₂      x₃

如果嵌入维度为 d m o d e l d_{model} dmodel,长度为 L L L 的序列会变成矩阵:

X ∈ R L × d m o d e l X \in \mathbb{R}^{L \times d_{model}} XRL×dmodel

加入 batch 后,工程中最常见的形状是 [B, L, D]

  • B B B:batch size
  • L L L:序列长度
  • D = d m o d e l D=d_{model} D=dmodel:每个 token 的特征维度

d m o d e l d_{model} dmodel 设为 312.这是一个可用的教学配置,不是 Transformer 的固定标准.实际项目会根据参数量、显存和任务难度选择 128、256、512、768 等不同维度.唯一需要特别注意的是:使用多头注意力时, d m o d e l d_{model} dmodel 通常应能被头数 h h h 整除.

仅有 Embedding 仍然不够.它更像一本"静态词典":同一个 token 在不同句子中查到的是同一个初始向量.编码器的核心目标,是让每个位置主动读取其他位置的信息,把静态向量更新为上下文化表示

H = Encoder ⁡ ( X ) , H ∈ R L × d m o d e l H = \operatorname{Encoder}(X), \qquad H \in \mathbb{R}^{L \times d_{model}} H=Encoder(X),HRL×dmodel

输入和输出的序列长度通常不变,但向量的语义已经发生变化.这正是 BERT 等编码器模型能够执行分类、序列标注、检索和表示学习的基础.


1.1输入层——初始词向量层和位置编码器层

初始词向量层和位置编码器层是数据输入最初的层,作用是将输入的序列通过计算组合成向量矩阵,如下图所示.

输入层

下面对每一部分依次进行讲解.

1.初始词向量层
如同大多数的向量构建方法一样,首先将每个输入单词通过词嵌入算法转换为词向量.其中每个词向量被设定为固定的维度,后面将所有词向量的维度设置为312.具体代码如下:

import torch
    word_embedding_table = torch.nn.Embedding(num_embeddings=encoder_vocab_size,
embedding_dim=312)
    encoder_embedding = word_embedding_table(inputs)

这里对代码进行解释,首先使用torch.nn.Embedding函数创建了一个随机初始化的向量矩阵,encoder_vocab_size是字库的个数,一般在编码器中字库是包含所有可能出现的"字"的集合.而embedding_dim定义的Embedding向量维度,这里使用通用的312即可.

词向量初始化在PyTorch中只发生在最底层的编码器中.额外讲一下,所有的编码器都有一个相同的特点,即它们接收一个向量列表,列表中的每个向量大小为312维.在底层(最开始)编码器中,它就是词向量,但是在其他编码器中,它就是下一层编码器的输出(也是一个向量列表).

2.位置编码
位置编码是一个既重要又有创新性的结构输入.一般自然语言处理使用的都是连续的长度序列,因此为了使用输入的顺序信息,需要将序列对应的相对位置和绝对位置信息注入模型中.

基于此目的,一个朴素的想法就是将位置编码设计成与词嵌入同样大小的向量维度,之后将其直接相加使用,从而使得模型能够既获取到词嵌入信息,也能获取到位置信息.
具体来说,位置向量的获取方式有以下两种:

  • 通过模型训练所得.
  • 根据特定的公式计算所得(用不同频率的sin和cos函数直接计算).

因此,在实际操作中,模型插入位置编码可以设计一个可以随模型训练的层,也可以使用一个计算
好的矩阵直接插入序列的位置函数,公式如下:

P E ( p o s , 2 i ) = sin ⁡ ( p o s 10000 2 i d m o d e l ) \mathrm{PE}_{(\mathrm{pos},2i)}= \sin\left( \frac{\mathrm{pos}}{10000^{\frac{2i}{d_{\mathrm{model}}}}} \right) PE(pos,2i)=sin(10000dmodel2ipos)

P E ( p o s , 2 i + 1 ) = cos ⁡ ( p o s 10000 2 i d m o d e l ) \mathrm{PE}_{(\mathrm{pos},2i+1)}= \cos\left( \frac{\mathrm{pos}}{10000^{\frac{2i}{d_{\mathrm{model}}}}} \right) PE(pos,2i+1)=cos(10000dmodel2ipos)

序列中任意一个位置都可以用三角函数表示,pos是输入序列的最大长度,i是序列中依次的各个位置,dmodel是设定的与词向量相同的位置312.代码如下:

class PositionalEncoding(torch.nn.Module):
    def __init__(self, d_model=312, dropout=0.05, max_len=80):
        """
        :param d_model: pe编码维度,一般与Word Embedding相同,方便相加
        :param dropout: drop out
        :param max_len: 语料库中最长句子的长度,即Word Embedding中的L
        """
        super(PositionalEncoding, self).__init__()

        # 定义drop out
        self.dropout = torch.nn.Dropout(p=dropout)

        # 计算pe编码
        pe = torch.zeros(max_len, d_model)
        # 建立空表,每行代表一个词的位置,每列代表一个编码位

        position = torch.arange(0, max_len).unsqueeze(1)
        # 建立arange表示词的位置,以便使用公式计算,size=(max_len, 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值
        pe[:, 1::2] = torch.cos(position * div_term)  # 计算奇数维度的pe值

        pe = pe.unsqueeze(0)
        # size=(1, L, d_model),便于与word_embedding相加

        self.register_buffer("pe", pe)  # pe值不参与训练

    def forward(self, x):
        # 输入的最终编码 = word_embedding + positional_embedding
        x = (
            x
            + self.pe[:, :x.size(1)]
            .clone()
            .detach()
            .requires_grad_(False)
        )

        return self.dropout(x)  # size=[batch, L, d_model]

这种位置编码函数的写法过于复杂,直接使用即可.最终将词向量矩阵和位置编码组合如图所示.

初始词向量

一个稳健的 PyTorch 实现如下:

import math
import torch
from torch import nn


class SinusoidalPositionalEncoding(nn.Module):
    def __init__(self, d_model: int, max_len: int = 512,
                 dropout: float = 0.1) -> None:
        super().__init__()

        positions = torch.arange(max_len, dtype=torch.float32).unsqueeze(1)
        frequencies = torch.exp(
            torch.arange(0, d_model, 2, dtype=torch.float32)
            * (-math.log(10_000.0) / d_model)
        )

        table = torch.zeros(max_len, d_model)
        table[:, 0::2] = torch.sin(positions * frequencies)
        table[:, 1::2] = torch.cos(
            positions * frequencies[:table[:, 1::2].shape[1]]
        )

        # table 不参与训练,但应跟随模型移动到 CPU/GPU。
        self.register_buffer("table", table.unsqueeze(0), persistent=False)
        self.dropout = nn.Dropout(dropout)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        # x: [B, L, D]
        return self.dropout(x + self.table[:, :x.size(1)])

这里使用 register_buffer,而不是把位置表定义成普通属性:这样它不会被优化器更新,却能随 model.to(device) 自动迁移设备.


1.2自注意力层

注意力层是使用注意力机制构建的,是能够脱离距离的限制建立相互关系的一种计算机制.注意力机制最早是在视觉图像领域提出的,来自于2014年"谷歌大脑"团队的论文Recurrent Models of VisualAttention,其在RNN模型上使用了注意力机制来进行图像分类.

随后,Bahdanau等在论文Neural Machine Translation by Jointly Learning to Align and Translate中,使用类似注意力的机制在机器翻译任务上将翻译和对齐同时进行,这实际上是第一次将注意力机制应用到NLP领域中.

接下来,注意力机制被广泛应用于基于RNN/CNN等神经网络模型的各种NLP任务中.2017年,Google机器翻译团队发表的Attention is all you need中大量使用了自注意力(Self-Attention)机制来学习文本表示.自注意力机制也成为大家近期的研究热点,并在各种自然语言处理任务上进行探索.

自然语言中的自注意力机制通常指的是不使用其他额外的信息,只使用自我注意力的形式关注本身,进而从句子中抽取相关信息.自注意力又称作内部注意力,它在很多任务上都有十分出色的表现,比如阅读理解、文本继承、自动文本摘要等.

下面将介绍一个简单的自注意机制.
1.自注意力中的Query、Key和Value
自注意力机制是进行自我关注从而抽取相关信息的机制.从具体实现来看,注意力函数的本质可以被描述为一个查询(Query)到一系列键-值(key-value)对的映射,它们被作为一种抽象的向量,主要用于计算和辅助自注意力,如下图所示.

自注意力机制

如图所示,一个单词Thinking经过向量初始化后,经过3个不同的全连接层重新计算后获取特定
维度的值,即看到的q1,而q2的来历也是如此.单词Machines经过Embedding向量初始化后,经过与上一个单词相同的全连接层计算,之后依次将q1和q2连接起来,组成一个新的连接后的二维矩阵WQ,被定义成Query.

而由于是自注意力机制,因此Key和Value和Query的值相同,如下图所示.

自注意力层中的Query、Key和Value

2.使用Query、Key和Value计算自注意力的值
下面使用Query、Key和Value计算自注意力的值,其过程如下:
(1)将Query和每个Key进行相似度计算得到权重,常用的相似度函数有点积、拼接、感知机等,这里使用的是点积计算,如下图所示.

点积计算

(2)使用一个Softmax函数对这些权重进行归一化.
Softmax函数的作用是计算不同输入之间的权重"分数",又称为权重系数.例如,正在考虑Thinkin
g这个词,就用它的q1乘以每个位置的ki,随后将得分加以处理再传递给Softmax,然后通过Softmax计算,其目的是使分数归一化,如下图所示.

这个Softmax计算分数决定了每个单词在该位置表达的程度.相关联的单词将具有相应位置上最高的Softmax分数.用这个得分乘以每个Value向量,可以增强需要关注单词的值,或者降低对不相关单词的关注度.

使用Softmax函数

Softmax的分数决定了当前单词在每个句子中每个单词位置的表示程度.很明显,当前单词对应句子中此单词所在位置的Softmax的分数最高,但是有时attention机制也能关注到此单词外的其他单词.

(3)每个Value向量乘以Softmax后的得分,如下图所示.
累加计算相关向量.这会在此位置产生自注意力层的输出(对于第一个单词),即将权重和相应的
键值Value进行加权求和,得到最后的注意力值.

乘以Softmax

总结自注意力的计算过程,根据输入的query与key计算两者之间的相似性或相关性,之后通过一个Softmax来对值进行归一化处理,获得注意力权重值,然后对Value进行加权求和,并得到最终的Attention数值.然而,在实际的实现过程中,该计算会以矩阵的形式完成,以便更快地处理.自注意力公式如下:
Attention ⁡ ( Query ⁡ , Source ⁡ ) = ∑ i = 1 L x Similarity ⁡ ( Query ⁡ , key ⁡ i ) × Value ⁡ i \operatorname{Attention}(\operatorname{Query}, \operatorname{Source})= \sum_{i=1}^{L_x} \operatorname{Similarity}(\operatorname{Query}, \operatorname{key}_i) \times \operatorname{Value}_i Attention(Query,Source)=i=1LxSimilarity(Query,keyi)×Valuei

换成更为通用的矩阵点积的形式来实现,其结构和形式如下图所示.

矩阵点积

3.自注意力的代码实现
下面进行自注意力的代码实现,实际上通过上面两步的讲解,自注意力模型的基本架构其实并不复杂,基本代码如下(仅供演示).

import torch
     import math
     import einops.layers.torch as elt
     # word_embedding_table =
torch.nn.Embedding(num_embeddings=encoder_vocab_size,embedding_dim=312)
     # encoder_embedding = word_embedding_table(inputs)
     
     vocab_size = 1024   #字符的种类
     embedding_dim = 312
     hidden_dim = 256
     token = torch.ones(size=(5,80),dtype=int)
     #创建一个输入Embedding值
     input_embedding =
torch.nn.Embedding(num_embeddings=vocab_size,embedding_dim=embedding_dim)(token)
     
     #对输入的input_embedding进行修正,这里进行了简写
     query = torch.nn.Linear(embedding_dim,hidden_dim)(input_embedding)
     key = torch.nn.Linear(embedding_dim,hidden_dim)(input_embedding)
     value = torch.nn.Linear(embedding_dim,hidden_dim)(input_embedding)
     
     key = elt.Rearrange("b l d -> b d l")(key)
     #计算query与key之间的权重系数
     attention_prob = torch.matmul(query,key)
     
     #使用softmax对权重系数进行归一化计算
     attention_prob = torch.softmax(attention_prob,dim=-1)
     
     #计算权重系数与value的值,从而获取注意力值
     attention_score = torch.matmul(attention_prob,value)
     
     print(attention_score.shape)

核心代码实现起来实际上很简单,这里先掌握这些核心代码即可.

换个角度,从概念上对注意力机制进行解释,注意力机制可以理解为从大量信息中有选择地筛选出少量重要信息并聚焦到这些重要信息上,忽略大多不重要的信息,这种思路仍然成立.聚焦的过程体现在权重系数的计算上,权重越大,越聚焦于其对应的Value值上,即权重代表信息的重要性,而权重与Value的点积是其对应的最终信息.

完整的注意力层代码如下.这里需要注意的是,在实现Attention的完整代码中,相对于前面的代码段,在这里加入了mask部分,用于在计算时忽略为了将所有的序列padding成一样的长度而进行的掩码计算的操作,具体在下面会介绍.

import torch
     import math
     import einops.layers.torch as elt
     
     class Attention(torch.nn.Module):
        def __init__(self,embedding_dim = 312,hidden_dim = 256):
           super().__init__()
           self.query_layer = torch.nn.Linear(embedding_dim, hidden_dim)
           self.key_layer = torch.nn.Linear(embedding_dim, hidden_dim)
           self.value_layer = torch.nn.Linear(embedding_dim, hidden_dim)
     
        def forward(self,embedding,mask):
           input_embedding = embedding
     
           query = self.query_layer(input_embedding)
           key = self.key_layer(input_embedding)
           value = self.value_layer(input_embedding)
     
           key = elt.Rearrange("b l d -> b d l")(key)
           # 计算query与key之间的权重系数
           attention_prob = torch.matmul(query, key)
     
           # 使用softmax对权重系数进行归一化计算
           attention_prob += mask * -1e5  # 在自注意力权重基础上加上掩码值
           attention_prob = torch.softmax(attention_prob, dim=-1)
     
           # 计算权重系数与value的值,从而获取注意力值
           attention_score = torch.matmul(attention_prob, value)
           return (attention_score)

具体结果请自行打印查阅.

用"带条件的检索"理解 Q、K、V
Q、K、V 往往是初学者第一次遇到注意力时最难跨过的门槛.可以把注意力想象成一次可学习的检索:

  • Query(查询):当前位置正在寻找什么信息
  • Key(键):每个位置用什么特征参与匹配
  • Value(值):匹配成功后,真正取回什么内容

假设模型正在更新"苹果"这个位置.在"我买了一部苹果手机"中,它的 Query 可能与"手机"的 Key 高度匹配,于是读取更多"手机"的 Value;在"我吃了一个苹果"中,它又可能更关注"吃".同一个 token 因为上下文不同,最终产生不同表示.


Q 决定"要找什么",K负责"如何被匹配",Softmax 形成权重,V 则是按权重取回并汇总的内容.

需要纠正一个常见误解:在自注意力中,Q、K、V 都来源于同一输入 X X X,但它们通常不是三个相同的数值.它们由三组不同的可学习矩阵投影得到:

Q = X W Q , K = X W K , V = X W V Q=XW^Q,\qquad K=XW^K,\qquad V=XW^V Q=XWQ,K=XWK,V=XWV

"Self"描述的是信息来源相同,而不是投影结果相同.


1.3ticks和Layer Normalization

我们基于PyTorch 2.0自定义层的形式编写了注意力模型的代码.与演示的代码有区别的是,实战代码中在这一部分的自注意层中还额外加入了mask值,即掩码层.掩码层的作用是获取输入序列的"有意义的值",而忽视本身就是用作填充或补全序列的值.一般用0表示有意义的值,而用1
表示填充值(这点并不固定,0和1的意思可以互换).[2,3,4,5,5,4,0,0,0] -> [0,0,0,0,0,0,1,1,1].
掩码计算的代码如下:

def create_padding_mark(seq):
        mask = torch.not_equal(seq, 0).float()
        mask = torch.unsqueeze(mask, dim=-1)
        return mask

此外,计算出的Query与Key的点积还需要除以一个常数,其作用是缩小点积的值以方便进行Softmax计算.这常被称为ticks,即采用一个小技巧使得模型训练能够更加准确和便捷.Layer
Normalization函数也是如此.下面对其进行详细介绍.

Layer Normalization函数是专门用作对序列进行整形的函数,其目的是防止字符序列在计算过程中发散,从而使得神经网络在拟合的过程中受影响.PyTorch 2.0中对LayerNormalization的使用准备了高级API,调用如下:

layer_norm = torch.nn.LayerNorm(normalized_shape, eps=1e-05, elementwise_affine=True,
device=None, dtype=None)函数
    embedding = layer_norm(embedding)  #使用layer_norm对输入数据进行处理

下图展示了Layer Normalization函数与BatchNormalization函数的不同.从图中可以看到,BatchNormalization是对一个batch中不同序列中处于同一位置的数据进行归一化计算,而LayerNormalization是对同一序列中不同位置的数据进行归一化处理.

Layer Normalization函数与Batch Normalization函数的不同

有兴趣的可以展开学习,这里就不再过多阐述了.具体的使用如下(注意一定要显式声明归一
化的维度):

embedding = torch.rand(size=(5,80,312))
     print(torch.nn.LayerNorm(normalized_shape=[80,312])(embedding).shape) #显式声明归一化的维度

1.4多头注意力

最后实现了使用PyTorch 2.0自定义层编写自注意力模型.从中可以看到,除了使用自注意力核心模型以外,还额外加入了掩码层和点积的除法运算,以及为了整形所使用的LayerNormalization函数.实际上,这些都是为了使得整体模型在训练时更加简易和便捷而做出的优化.

大家应该发现了,前面无论是掩码计算、点积计算还是使用Layer Normalization,都是在一些细枝末节上的修补,有没有可能对注意力模型进行较大的结构调整,使其更加适应模型的训练?

下面在此基础上介绍一种较为大型的ticks,即多头注意力(Multi-HeadAttention)架构,该架构在原始的自注意力模型的基础上做出了较大的优化.

多头注意力架构如下图所示,Query、Key、Value首先经过一个线性变换,之后计算相互之间的注意力值.相对于原始自注意计算方法,注意这里的计算要做h次(h为"头"的数目),其实也就是所谓的多头,每次算一个头,而每次Query、Key、Value进行线性变换的参数W是不一样的.

多头注意力架构

将h次缩放点积注意力值的结果进行拼接,再进行一次线性变换,得到的值作为多头注意力的结果,如下图所示.

多头注意力的结果

可以看到,这样计算得到的多头注意力值的不同之处在于,进行了h次计算,而不只是计算一次.这样做的好处是可以允许模型在不同的表示子空间中学习到相关的信息,并且相对于单独的注意力模型,多头注意力模型的计算复杂度大大降低了.拆分多头模型的代码如下:

def splite_tensor(tensor,h_head):
        embedding = elt.Rearrange("b l (h d) -> b l h d",h = h_head)(tensor)
        embedding = elt.Rearrange("b l h d -> b h l d", h=h_head)(embedding)
        return embedding

在此基础上,可以对注意力模型进行修正,新的多头注意力层代码如下:

class Attention(torch.nn.Module):
        def __init__(self,embedding_dim = 312,hidden_dim = 312,n_head = 6):
           super().__init__()
           self.n_head = n_head
           self.query_layer = torch.nn.Linear(embedding_dim, hidden_dim)
           self.key_layer = torch.nn.Linear(embedding_dim, hidden_dim)
           self.value_layer = torch.nn.Linear(embedding_dim, hidden_dim)
     
        def forward(self,embedding,mask):
           input_embedding = embedding
           query = self.query_layer(input_embedding)
           key = self.key_layer(input_embedding)
           value = self.value_layer(input_embedding)
           query_splited = self.splite_tensor(query,self.n_head)
           key_splited = self.splite_tensor(key,self.n_head)
           value_splited = self.splite_tensor(value,self.n_head)
     
           key_splited = elt.Rearrange("b h l d -> b h d l")(key_splited)
           # 计算query与key之间的权重系数
           attention_prob = torch.matmul(query_splited, key_splited)
     
           # 使用softmax对权重系数进行归一化计算
           attention_prob += mask * -1e5  # 在自注意力权重的基础上加上掩码值
           attention_prob = torch.softmax(attention_prob, dim=-1)
           # 计算权重系数与value的值,从而获取注意力值
           attention_score = torch.matmul(attention_prob, value_splited)
           attention_score = elt.Rearrange("b h l d -> b l (h d)")(attention_score)
           return (attention_score)
     
        def splite_tensor(self,tensor,h_head):
           embedding = elt.Rearrange("b l (h d) -> b l h d",h = h_head)(tensor)
           embedding = elt.Rearrange("b l h d -> b h l d", h=h_head)(embedding)
           return embedding
     
     if __name__ == '__main__':
        embedding = torch.rand(size=(5,16,312))
        mask = torch.ones((5,1,16,1)) #注意设计mask的位置,长度是16
        Attention()(embedding,mask)

相比较单一的注意力模型,多头注意力模型能够简化计算,并且在更多维的空间对数据进行整合.最新的研究表明,实际上使用"多头"注意力模型,每个"头"所关注的内容并不一致,有的"头"关注相邻之间的序列,而有的"头"会关注更远处的单词.下图展示了一个8头注意力模型的架构,具体请自行实现.

8头注意力模型的架构

多头注意力为什么不只做一次
单头注意力只能在一套投影空间里计算关系.多头注意力为每个头使用不同的 W i Q , W i K , W i V W_i^Q,W_i^K,W_i^V WiQ,WiK,WiV,使多个子空间并行学习:

h e a d i = Attention ⁡ ( X W i Q , X W i K , X W i V ) head_i=\operatorname{Attention}(XW_i^Q,XW_i^K,XW_i^V) headi=Attention(XWiQ,XWiK,XWiV)

MultiHead ⁡ ( X ) = Concat ⁡ ( h e a d 1 , … , h e a d h ) W O \operatorname{MultiHead}(X)= \operatorname{Concat}(head_1,\dots,head_h)W^O MultiHead(X)=Concat(head1,,headh)WO

不同头可能分别偏向局部搭配、远距离依赖、句法关系或实体关联,但这不是人工预先规定的,而是训练中涌现的分工.


多个注意力头使用独立投影,从不同"观察角度"读取同一序列;各头结果拼接后再映射回模型维度.

还要澄清一点:在固定 d m o d e l d_{model} dmodel 的常见设置下,多头注意力把总维度拆到多个头中,其总体计算量与同维度单头大致处于同一数量级,核心优势是多子空间表达能力,而不是"头越多,计算复杂度就必然越低".序列较长时, L × L L\times L L×L 注意力矩阵仍然是显存和计算瓶颈.

头数也不是越多越好.头数增加会让每头维度 d h d_h dh 变小;如果每头表达空间过窄,模型性能反而可能下降.使用 d m o d e l = 312 d_{model}=312 dmodel=312 h = 8 h=8 h=8 时,每头维度为 39,数学上可以整除.


2.编码器的实现

下面开始介绍编码器的写法.

前面对编码器的核心部件——注意力模型做了介绍,并且对输入端的词嵌入初始化方法和位置编码做了介绍,正如一开始所介绍的,将使用transformer的编码器方案来构建,这是目前最为常用的架构方案.

从下图中可以看到,一个编码器的构建分成3部分:初始向量层、注意力层和前馈层.

编码器的构建

初始向量层和注意力层在前面已经介绍完毕,下面将介绍最后一部分:前馈层.之后将使用这3部分构建编码器架构.


2.1前馈层的实现

从编码器输入的序列经过一个自注意力层后,会传递到前馈神经网络中,这个神经网络被称为"前
馈层".这个前馈层的作用是进一步整形通过注意力层获取的整体序列向量.

解码器遵循的是transformer架构,因此参考transformer中解码器的构建,如下图所示.相信看到一定会很诧异,是否放错图了?并没有.

transformer中解码器的构建

所谓前馈神经网络,实际上就是加载了激活函数的全连接层神经网络(或者使用一维卷积实现的神经网络,这点不在这里介绍).既然了解了前馈神经网络,其实现也很简单,代码如下.

import torch
     
     class FeedForWard(torch.nn.Module):
        def __init__(self,embdding_dim = 312,scale = 4):
        super().__init__()
        self.linear1 = torch.nn.Linear(embdding_dim,embdding_dim*scale)
        self.relu_1 = torch.nn.ReLU()
        self.linear2 = torch.nn.Linear(embdding_dim*scale,embdding_dim)
        self.relu_2 = torch.nn.ReLU()
        self.layer_norm = torch.nn.LayerNorm(normalized_shape=embdding_dim)
     def forward(self,tensor):
        embedding = self.linear1(tensor)
        embedding = self.relu_1(embedding)
        embedding = self.linear2(embedding)
        embedding = self.relu_2(embedding)
        embedding = self.layer_norm(embedding)
        return embedding

代码很简单,需要提醒的是,以上代码使用了两个全连接神经网络来实现前馈神经网络,然而实际上为了减少参数,减轻运行负担,可以使用一维卷积或者"空洞卷积"替代全连接层实现前馈神经
网络,具体可以自行完成.


2.2编码器的实现

经过前面的分析可以得知,实现一个transformer架构的编码器并不困难,只需要按架构依次将其组合在一起即可.下面按步提供代码,可参考注释进行学习.

import math

import torch
import einops.layers.torch as elt


class FeedForWard(torch.nn.Module):
    def __init__(self, embedding_dim=312, scale=4):
        super().__init__()

        self.linear1 = torch.nn.Linear(
            embedding_dim,
            embedding_dim * scale
        )
        self.relu_1 = torch.nn.ReLU()

        self.linear2 = torch.nn.Linear(
            embedding_dim * scale,
            embedding_dim
        )
        self.relu_2 = torch.nn.ReLU()

        self.layer_norm = torch.nn.LayerNorm(
            normalized_shape=embedding_dim
        )

    def forward(self, tensor):
        embedding = self.linear1(tensor)
        embedding = self.relu_1(embedding)

        embedding = self.linear2(embedding)
        embedding = self.relu_2(embedding)

        embedding = self.layer_norm(embedding)

        return embedding


class Attention(torch.nn.Module):
    def __init__(
        self,
        embedding_dim=312,
        hidden_dim=312,
        n_head=6
    ):
        super().__init__()

        self.n_head = n_head

        self.query_layer = torch.nn.Linear(
            embedding_dim,
            hidden_dim
        )
        self.key_layer = torch.nn.Linear(
            embedding_dim,
            hidden_dim
        )
        self.value_layer = torch.nn.Linear(
            embedding_dim,
            hidden_dim
        )

    def forward(self, embedding, mask):
        input_embedding = embedding

        query = self.query_layer(input_embedding)
        key = self.key_layer(input_embedding)
        value = self.value_layer(input_embedding)

        query_splited = self.splite_tensor(
            query,
            self.n_head
        )
        key_splited = self.splite_tensor(
            key,
            self.n_head
        )
        value_splited = self.splite_tensor(
            value,
            self.n_head
        )

        key_splited = elt.Rearrange(
            "b h l d -> b h d l"
        )(key_splited)

        # 计算 query 与 key 之间的权重系数
        attention_prob = torch.matmul(
            query_splited,
            key_splited
        )

        # 使用 softmax 对权重系数进行归一化计算
        # 在自注意力权重的基础上加上掩码值
        attention_prob += mask * -1e5
        attention_prob = torch.softmax(
            attention_prob,
            dim=-1
        )

        # 计算权重系数与 value 的值,从而获取注意力值
        attention_score = torch.matmul(
            attention_prob,
            value_splited
        )

        attention_score = elt.Rearrange(
            "b h l d -> b l (h d)"
        )(attention_score)

        return attention_score

    def splite_tensor(self, tensor, h_head):
        embedding = elt.Rearrange(
            "b l (h d) -> b l h d",
            h=h_head
        )(tensor)

        embedding = elt.Rearrange(
            "b l h d -> b h l d",
            h=h_head
        )(embedding)

        return embedding


class PositionalEncoding(torch.nn.Module):
    def __init__(
        self,
        d_model=312,
        dropout=0.05,
        max_len=80
    ):
        """
        :param d_model: PE 编码维度,一般与 Word Embedding 相同,方便相加
        :param dropout: dropout
        :param max_len: 语料库中最长句子的长度,即 Word Embedding 中的 L
        """
        super(PositionalEncoding, self).__init__()

        # 定义 dropout
        self.dropout = torch.nn.Dropout(p=dropout)

        # 计算 PE 编码
        # 每行代表一个词的位置,每列代表一个编码维度
        pe = torch.zeros(max_len, d_model)

        # 表示词的位置,size = (max_len, 1)
        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 值
        pe[:, 0::2] = torch.sin(position * div_term)

        # 计算奇数维度的 PE 值
        pe[:, 1::2] = torch.cos(position * div_term)

        # size = (1, L, d_model),方便与词向量相加
        pe = pe.unsqueeze(0)

        # PE 不参与模型训练
        self.register_buffer("pe", pe)

    def forward(self, x):
        # 最终输入编码 = 词向量编码 + 位置编码
        x = (
            x
            + self.pe[:, :x.size(1)]
            .clone()
            .detach()
            .requires_grad_(False)
        )

        # size = [batch, L, d_model]
        return self.dropout(x)


class Encoder(torch.nn.Module):
    def __init__(
        self,
        vocab_size=1024,
        max_length=80,
        embedding_size=312,
        n_head=6,
        scale=4,
        n_layer=3
    ):
        super().__init__()

        self.n_layer = n_layer

        self.embedding_table = torch.nn.Embedding(
            num_embeddings=vocab_size,
            embedding_dim=embedding_size
        )

        self.position_embedding = PositionalEncoding(
            max_len=max_length
        )

        self.attention = Attention(
            embedding_size,
            embedding_size,
            n_head
        )

        self.feedward = FeedForWard()

    def forward(self, token_inputs):
        token = token_inputs
        mask = self.create_mask(token)

        embedding = self.embedding_table(token)
        embedding = self.position_embedding(embedding)

        for _ in range(self.n_layer):
            embedding = self.attention(
                embedding,
                mask
            )

            embedding = torch.nn.Dropout(0.1)(
                embedding
            )

            embedding = self.feedward(embedding)

        return embedding

    def create_mask(self, seq):
        mask = torch.not_equal(seq, 0).float()
        mask = torch.unsqueeze(mask, dim=-1)
        mask = torch.unsqueeze(mask, dim=1)

        return mask


if __name__ == "__main__":
    seq = torch.ones(
        size=(3, 80),
        dtype=int
    )

    Encoder()(seq)

3.实战编码器:拼音汉字转化模型

下面将结合前面两节的内容实战编码器,即使用编码器完成一个训练——拼音与汉字的转化,类似下面的效果.


3.1汉字拼音数据集处理

首先是数据集的准备和处理,在本例中准备了15万条汉字和拼音对应的数据.
1.数据集展示
汉字拼音数据集如下:

简单介绍一下.数据集中的数据被分成3部分,每部分使用特定的空格键隔开:A11_10 … … … ke3 shei2 … … …可 谁 … … …

  • 第一部分A11_i为序号,表示序列的条数和行号.
  • 第二部分是拼音编号,这里使用的是汉语拼音,与真实的拼音标注不同的是,去除了拼音的原始标注,而使用数字1、2、3、4替代,分别代表当前读音的第一声到第四声,这点请注意.
  • 最后一部分是汉字序列,这里与第二部分的拼音部分一一对应.

2.获取字库和训练数据
获取数据集中字库的个数是一个非常重要的问题,一个非常好的办法是使用set格式的数据读取全部字库中的不同字符.创建字库和训练数据的完整代码如下:

max_length = 64

with open("zh.tsv", errors="ignore", encoding="UTF-8") as f:
    context = f.readlines()  # 读取内容

    for line in context:
        line = line.strip().split("\t")  # 切分每行中的不同部分

        # 处理拼音部分,在头尾加上起止符号
        pinyin = ["GO"] + line[1].split(" ") + ["END"]

        # 处理汉字部分,在头尾加上起止符号
        hanzi = ["GO"] + line[2].split(" ") + ["END"]

        # 创建字典
        for _pinyin, _hanzi in zip(pinyin, hanzi):
            pinyin_vocab.add(_pinyin)
            hanzi_vocab.add(_hanzi)

        pinyin = pinyin + ["PAD"] * (max_length - len(pinyin))
        hanzi = hanzi + ["PAD"] * (max_length - len(hanzi))

        pinyin_list.append(pinyin)  # 创建拼音列表
        hanzi_list.append(hanzi)    # 创建汉字列表

这里说明一下,首先context读取了全部数据集中的内容,之后根据空格将其分成3部分.对于拼音和汉字部分,将其转化成一个序列,并在前后分别加上起止符GO和END.这实际上可以不用加,为了明确地描述起止关系,从而加上了起止标注.

实际上还需要加上一个特定符号PAD,这是为了对单行序列进行补全操作,最终的数据如下:

['GO', 'liu2', 'yong3' , … … … , 'gan1', ' END', ‘PAD', ‘PAD' , … … …]
     ['GO', '柳', '永' , … … … , '感', ' END', ‘PAD', ‘PAD' , … … …]

pinyin_list和hanzi_list是两个列表,分别用来存放对应的拼音和汉字训练数据.最后不要忘记在字库中加上PAD符号.

pinyin_vocab = ["PAD"] + list(sorted(pinyin_vocab))
 hanzi_vocab = ["PAD"] + list(sorted(hanzi_vocab))

3.根据字库生成Token数据
获取的拼音标注和汉字标注的训练数据并不能直接用于模型训练,模型需要转化成token的一系列数字列表,代码如下:

def get_dataset():
    pinyin_tokens_ids = []  # 新的拼音 token 列表
    hanzi_tokens_ids = []   # 新的汉字 token 列表

    for pinyin, hanzi in zip(tqdm(pinyin_list), hanzi_list):
        # 获取新的拼音 token
        pinyin_tokens_ids.append(
            [pinyin_vocab.index(char) for char in pinyin]
        )

        # 获取新的汉字 token
        hanzi_tokens_ids.append(
            [hanzi_vocab.index(char) for char in hanzi]
        )

    return pinyin_vocab, hanzi_vocab, pinyin_tokens_ids, hanzi_tokens_ids

代码中创建了两个新的列表,分别对拼音和汉字的token进行存储,获取的是根据字库序号编号后新的序列token.


3.2汉字拼音转化模型的确定

下面进行模型的编写.实际上,单纯使用在前面提供的模型也是可以的,但是一般来说需要对其进行修正.因此,单纯使用一层编码器对数据进行编码,在效果上可能并没有多层编码器的准确率高,一个简单方法是增加更多层的编码器对数据进行编码,如下图所示.

使用多层编码器进行编码

代码如下.

import math

import torch
import einops.layers.torch as elt


class FeedForward(torch.nn.Module):
    def __init__(self, embedding_dim=312, scale=4):
        super().__init__()

        self.linear1 = torch.nn.Linear(
            embedding_dim,
            embedding_dim * scale
        )
        self.relu_1 = torch.nn.ReLU()

        self.linear2 = torch.nn.Linear(
            embedding_dim * scale,
            embedding_dim
        )
        self.relu_2 = torch.nn.ReLU()

        self.layer_norm = torch.nn.LayerNorm(
            normalized_shape=embedding_dim
        )

    def forward(self, tensor):
        embedding = self.linear1(tensor)
        embedding = self.relu_1(embedding)

        embedding = self.linear2(embedding)
        embedding = self.relu_2(embedding)

        embedding = self.layer_norm(embedding)

        return embedding


class Attention(torch.nn.Module):
    def __init__(
        self,
        embedding_dim=312,
        hidden_dim=312,
        n_head=6
    ):
        super().__init__()

        if hidden_dim % n_head != 0:
            raise ValueError("hidden_dim 必须能被 n_head 整除")

        self.n_head = n_head
        self.head_dim = hidden_dim // n_head

        self.query_layer = torch.nn.Linear(
            embedding_dim,
            hidden_dim
        )
        self.key_layer = torch.nn.Linear(
            embedding_dim,
            hidden_dim
        )
        self.value_layer = torch.nn.Linear(
            embedding_dim,
            hidden_dim
        )

    def forward(self, embedding, mask=None):
        query = self.query_layer(embedding)
        key = self.key_layer(embedding)
        value = self.value_layer(embedding)

        query_splited = self.split_tensor(query)
        key_splited = self.split_tensor(key)
        value_splited = self.split_tensor(value)

        key_splited = elt.Rearrange(
            "b h l d -> b h d l"
        )(key_splited)

        # 计算 Query 和 Key 之间的注意力分数
        attention_prob = torch.matmul(
            query_splited,
            key_splited
        )

        # 缩放点积注意力
        attention_prob = attention_prob / math.sqrt(
            self.head_dim
        )

        # mask 的形状为 [batch, 1, 1, sequence_length]
        if mask is not None:
            attention_prob = attention_prob.masked_fill(
                mask,
                -1e9
            )

        attention_prob = torch.softmax(
            attention_prob,
            dim=-1
        )

        # 注意力权重与 Value 相乘
        attention_score = torch.matmul(
            attention_prob,
            value_splited
        )

        # 合并多头
        attention_score = elt.Rearrange(
            "b h l d -> b l (h d)"
        )(attention_score)

        return attention_score

    def split_tensor(self, tensor):
        embedding = elt.Rearrange(
            "b l (h d) -> b h l d",
            h=self.n_head
        )(tensor)

        return embedding


class PositionalEncoding(torch.nn.Module):
    def __init__(
        self,
        d_model=312,
        dropout=0.05,
        max_len=80
    ):
        """
        Args:
            d_model:
                位置编码维度,一般与词嵌入维度相同。
            dropout:
                Dropout 概率。
            max_len:
                支持的最大序列长度。
        """
        super().__init__()

        self.dropout = torch.nn.Dropout(p=dropout)

        # pe 的形状为 [max_len, d_model]
        pe = torch.zeros(max_len, d_model)

        # position 的形状为 [max_len, 1]
        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)

        # 转换为 [1, max_len, d_model]
        pe = pe.unsqueeze(0)

        # 位置编码不参与训练
        self.register_buffer("pe", pe)

    def forward(self, x):
        sequence_length = x.size(1)

        if sequence_length > self.pe.size(1):
            raise ValueError(
                f"输入长度 {sequence_length} 超过最大长度 "
                f"{self.pe.size(1)}"
            )

        # 最终编码 = 词嵌入 + 位置编码
        x = x + self.pe[:, :sequence_length]

        return self.dropout(x)


class Encoder(torch.nn.Module):
    def __init__(
        self,
        vocab_size=1024,
        max_length=80,
        embedding_size=312,
        n_head=6,
        scale=4,
        n_layer=3
    ):
        super().__init__()

        self.n_layer = n_layer

        self.embedding_table = torch.nn.Embedding(
            num_embeddings=vocab_size,
            embedding_dim=embedding_size,
            padding_idx=0
        )

        self.position_embedding = PositionalEncoding(
            d_model=embedding_size,
            max_len=max_length
        )

        self.attention = Attention(
            embedding_dim=embedding_size,
            hidden_dim=embedding_size,
            n_head=n_head
        )

        self.dropout = torch.nn.Dropout(0.1)

        self.feedforward = FeedForward(
            embedding_dim=embedding_size,
            scale=scale
        )

    def forward(self, token_inputs):
        """
        Args:
            token_inputs:
                Token ID,形状为
                [batch_size, sequence_length]。

        Returns:
            编码结果,形状为
            [batch_size, sequence_length, embedding_size]。
        """
        mask = self.create_mask(token_inputs)

        embedding = self.embedding_table(token_inputs)
        embedding = self.position_embedding(embedding)

        for _ in range(self.n_layer):
            embedding = self.attention(
                embedding,
                mask
            )
            embedding = self.dropout(embedding)
            embedding = self.feedforward(embedding)

        return embedding

    @staticmethod
    def create_mask(seq):
        """
        创建 Padding Mask。

        假设 Token ID 0 表示 PAD。

        返回形状:
            [batch_size, 1, 1, sequence_length]
        """
        mask = seq.eq(0)
        mask = mask.unsqueeze(1).unsqueeze(2)

        return mask


if __name__ == "__main__":
    model = Encoder(
        vocab_size=1024,
        max_length=80,
        embedding_size=312,
        n_head=6,
        scale=4,
        n_layer=3
    )

    # 示例输入:[batch_size=2, sequence_length=8]
    tokens = torch.tensor([
        [1, 23, 45, 67, 89, 2, 0, 0],
        [1, 12, 34, 56, 2, 0, 0, 0]
    ])

    output = model(tokens)

    print("输入形状:", tokens.shape)
    print("输出形状:", output.shape)

这里相对于前面的编码器构建示例,使用了多头自注意力层和前馈层,需要注意的是,这里只是在编码器层中加入了更多层的多头注意力层和前馈层,而不是直接加载了更多的编码器.


3.3模型训练部分的编写

剩下的是对模型的训练部分的编写.在这里采用简单的模型训练的方式完成代码的编写.
第一步:导入数据集和创建数据的生成函数.
对于数据的获取,由于模型在训练过程中不可能一次性将所有的数据导入,因此需要创建一个生成器,将获取的数据按批次发送给训练模型,在这里我们使用一个for循环来完成这个数据输入任务.

pinyin_vocab,hanzi_vocab,pinyin_tokens_ids,hanzi_tokens_ids = get_data.get_dataset()
     
     batch_size = 32
     train_length = len(pinyin_tokens_ids)
     for epoch in range(21):
        train_num = train_length // batch_size
        train_loss, train_correct = [], []
     
        for i in tqdm(range((train_num))):

这段代码是数据的生成工作,按既定的batch_size大小生成数据batch,之后在epoch的循环中对数据输入进行迭代.
下面是训练模型的完整实战,代码如下.

import numpy as np
import torch
import attention_model
import get_data
from tqdm import tqdm

max_length = 64
char_vocab_size = 4462
pinyin_vocab_size = 1154


def get_model(embedding_dim=312):
    model = torch.nn.Sequential(
        attention_model.Encoder(
            pinyin_vocab_size,
            max_length=max_length
        ),
        torch.nn.Dropout(0.1),
        torch.nn.Linear(
            embedding_dim,
            char_vocab_size
        )
    )

    return model


device = "cuda"

model = get_model().to(device)
model = torch.compile(model)

optimizer = torch.optim.Adam(
    model.parameters(),
    lr=3e-5
)

loss_func = torch.nn.CrossEntropyLoss()

pinyin_vocab, hanzi_vocab, pinyin_tokens_ids, hanzi_tokens_ids = (
    get_data.get_dataset())
batch_size = 32
train_length = len(pinyin_tokens_ids)

for epoch in range(21):
    train_num = train_length // batch_size
    train_loss, train_correct = [], []

    for i in tqdm(range(train_num)):
        model.zero_grad()

        start = i * batch_size
        end = (i + 1) * batch_size

        batch_input_ids = torch.tensor(
            pinyin_tokens_ids[start:end]
        ).int().to(device)

        batch_labels = torch.tensor(
            hanzi_tokens_ids[start:end]
        ).to(device)

        pred = model(batch_input_ids)

        batch_labels = batch_labels.to(torch.uint8)
        active_loss = batch_labels.gt(0).view(-1) == 1

        loss = loss_func(
            pred.view(-1, char_vocab_size)[active_loss],
            batch_labels.view(-1)[active_loss]
        )

        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

    if (epoch + 1) % 10 == 0:
        state = {
            "net": model.state_dict(),
            "optimizer": optimizer.state_dict(),
            "epoch": epoch
        }
        torch.save(state, "./saver/modelpara.pt")

通过将训练代码部分和模型组合在一起,即可完成模型的训练.而最后预测部分,即使用模型进行
自定义实战拼音和汉字的转化,自行完成.


4.实战:把拼音转汉字建模为逐位置分类

拼音携带数字声调,例如 ni3 hao3 ma;每个拼音单元与一个汉字位置对应,并加入 [GO][END][PAD] 等特殊符号.


在输入输出严格对齐的教学设定中,拼音序列先经过上下文编码,再由每个位置的分类头预测对应汉字;补齐位置不计入损失.


4.1为什么上下文不可缺少

拼音到汉字是一对多映射.例如 shi4 可能对应"是"“事”“市”"式"等字.只做单 token 查表无法消歧,而自注意力可以让当前位置读取整句上下文:

wo3 xi3 huan1 ping2 guo3  -> 我喜欢苹果
ping2 guo3 shou3 ji1      -> 苹果手机

"苹果"本身的汉字相同,但周围词可以帮助模型形成不同的语义表示;对更多同音字而言,上下文直接决定最终选择.

位置编码也不可省略.即使拼音集合相同,顺序变化通常会产生完全不同的句子.Embedding 回答"有哪些拼音",位置编码和注意力共同回答"它们以什么顺序、在什么上下文中出现".


4.2数据管线

一个可靠的数据处理流程应包含:

  1. 读取拼音与汉字的对齐样本
  2. 分别构建拼音词表和汉字词表
  3. 显式保留 [PAD][UNK],根据任务决定是否需要 [GO][END]
  4. 把 token 转成整数 ID
  5. 在 batch 内补齐到相同长度
  6. 生成 valid_tokens = input_ids != pad_id
  7. 训练时让 [PAD] 标签不参与交叉熵

词表只能在训练集上拟合,验证集和测试集中未见 token 应映射到 [UNK],否则会产生数据泄漏.划分数据集时还要注意重复句、近重复句和同源语料,避免验证指标虚高.


4.3一个边界清晰的编码器模型

如果输入拼音和输出汉字严格一一对齐,可以把任务建模为序列标注:编码器为每个位置生成向量,线性层在该位置预测汉字类别.

class PinyinEncoderTagger(nn.Module):
    def __init__(self, pinyin_vocab_size: int,
                 hanzi_vocab_size: int,
                 d_model: int = 312,
                 num_heads: int = 8,
                 d_ff: int = 1248,
                 num_layers: int = 3,
                 pad_id: int = 0) -> None:
        super().__init__()
        self.pad_id = pad_id
        self.embedding = nn.Embedding(
            pinyin_vocab_size, d_model, padding_idx=pad_id
        )
        self.position = SinusoidalPositionalEncoding(d_model)
        self.layers = nn.ModuleList(
            EncoderBlock(d_model, num_heads, d_ff)
            for _ in range(num_layers)
        )
        self.classifier = nn.Linear(d_model, hanzi_vocab_size)

    def forward(self, token_ids: torch.Tensor) -> torch.Tensor:
        valid_tokens = token_ids.ne(self.pad_id)
        scale = math.sqrt(self.embedding.embedding_dim)
        x = self.position(self.embedding(token_ids) * scale)

        for layer in self.layers:
            x, _ = layer(x, valid_tokens)

        # [B, L, hanzi_vocab_size]
        return self.classifier(x)

训练时:

logits = model(pinyin_ids)
loss = nn.CrossEntropyLoss(ignore_index=pad_id)(
    logits.reshape(-1, logits.size(-1)),
    hanzi_ids.reshape(-1),
)

optimizer.zero_grad()
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
optimizer.step()

这里 ignore_index=pad_id 非常重要:注意力掩码阻止真实 token 读取补齐内容,损失掩码则阻止模型因预测 [PAD] 被奖励或惩罚.两者解决的是不同阶段的问题,不能互相替代.


4.4为什么这不等同于完整机器翻译

上述模型成立的前提是输入与输出长度一致、位置严格对齐.它本质上是"上下文增强的逐位置分类器",并不是通用的自回归翻译系统.

如果允许一个拼音单元对应多个输出、需要插入或删除 token、输出长度未知,或者希望按已生成内容逐步预测,就需要更完整的序列生成方案,例如编码器—解码器 Transformer、CTC 或其他对齐机制.只用双向编码器直接查看未来位置,还不能自然满足自回归生成的因果约束.

这个区分很重要:教学案例可以简化,但任务假设必须说清楚.


5.训练、评估与调试建议

5.1不要只看整体准确率

拼音转汉字至少可以关注:

  • [PAD] 位置的字准确率
  • 整句完全正确率
  • 同音高频字与低频字的分组准确率
  • 不同句长区间的表现
  • Top-k 准确率
  • 未登录词或罕见拼音组合的错误分布

若只统计包含大量 [PAD] 的整体 token 准确率,指标会被补齐位置"稀释".


5.2常见故障清单

故障 1:损失几乎不下降。

检查 Q/K/V 是否除以 d h \sqrt{d_h} dh 、学习率是否过大、标签范围是否落在汉字词表内,以及 Softmax 前是否把所有位置都错误屏蔽.

故障 2:输出几乎全是高频汉字。

检查类别分布、训练数据重复、同音字样本是否极度失衡.可以分析混淆矩阵,而不是立刻扩大模型.

故障 3:短句正常,长句明显变差。

检查训练集句长覆盖、最大位置编码长度和截断策略.若输入长度超过 max_len,固定位置表会直接越界或缺失.

故障 4:GPU 显存突然爆炸。

自注意力权重形状为 [B, h, L, L],序列长度翻倍时,核心注意力矩阵规模约增至 4 倍.优先检查 batch size 和句长,而不是只盯着参数量.

故障 5:更换掩码后出现 NaN。

检查是否存在全 [PAD] 样本,以及是否对一整行分数都填成了负无穷.混合精度下,使用 torch.finfo(scores.dtype).min 往往比手写超大负数更稳妥.


5.3建议记录的调试信息

第一次跑通模型时,至少打印或断言以下内容:

assert pinyin_ids.ndim == 2
assert logits.shape[:2] == pinyin_ids.shape
assert logits.size(-1) == hanzi_vocab_size
assert valid_tokens.dtype == torch.bool
assert d_model % num_heads == 0

还可以抽取一层、一个头的注意力矩阵进行可视化,但不要把注意力权重简单等同于模型的完整解释.它只展示某个内部路由信号,不能单独证明因果关系.


6.一张表记住整个编码器

模块输入/输出核心作用常见坑
Embedding[B,L] → [B,L,D]token ID 查表为向量词表泄漏、PAD 未固定
位置编码[B,L,D] → [B,L,D]注入顺序信息max_len 不够、设备不一致
Q/K/V 投影[B,L,D] → 3×[B,h,L,d_h]构造查询、匹配键和内容值误以为 Q=K=V 数值相同
缩放点积[B,h,L,d_h] → [B,h,L,L]计算位置相关性除错维度、Softmax 轴错误
Padding Mask[B,L] → 可广播掩码屏蔽补齐位置True/False 语义颠倒
多头合并[B,h,L,d_h] → [B,L,D]汇总多个表示子空间忘记 contiguous() 或输出投影
Add & Norm[B,L,D] → [B,L,D]保留信息、稳定训练残差形状不一致、Pre/Post-LN 混淆
FFN[B,L,D] → [B,L,D]逐位置非线性特征变换中间维度和激活选择不当
分类头[B,L,D] → [B,L,V]每位置输出类别分数对 PAD 位置计算损失

7.总结:编码器是一台"上下文重写器"

Transformer 编码器并不是简单地把字符"压成向量".它反复执行两类操作:

  1. 自注意力让不同位置交换信息
  2. 前馈网络让每个位置独立加工已经汇总的信息

位置编码提供顺序,Q/K/V 建立可学习的检索, d k \sqrt{d_k} dk 缩放和掩码保证注意力计算合理,残差连接与 LayerNorm 让深层训练更稳定,多头机制则让模型在多个表示子空间中并行观察关系.层层堆叠之后,每个 token 都不再是孤立的词典条目,而成为带有全局上下文的表示.

拼音转汉字案例很好地展示了这一点:真正困难的不是把 ni3 映射到某个固定汉字,而是利用整句上下文在多个候选字之间消歧.在严格对齐的设定下,编码器加逐位置分类头已经能形成一个清晰的教学模型;当任务涉及变长输出或自回归生成时,再自然过渡到编码器—解码器结构.

当你能在纸上写清楚 [B,L,D] → [B,h,L,d_h] → [B,h,L,L] → [B,L,D] 这条形状链路,并能解释每一个掩码屏蔽的是谁,Transformer 编码器就不再是一个黑盒.


🚀真正的勇者不是流泪的人,而是含泪奔跑的人!

敬请期待下一篇文章内容


每日心灵鸡汤: 暂时没有回报,不等于没有希望!

人在没有资源的时候,前期的付出与收益往往是不成正比的,因为这个阶段真正要完成的,不是获取结果,而是在构建自己:既包括能力和经验,也包括人格的稳定性,以及面对长期不确定的信念.很多人失败,不是因为能力不够,而是因为在系统尚未建成之前,就把暂时没有回报误以为没有希望.所谓忍耐,不是认输,而是一种认知能力:接受付出与收益在时间上的错位.想得开,是理解规律;挺得住,是相信规律.

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/2401_87629362/article/details/163221683

文章来源crawl

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--