💻 源码
import random
import re
import torch
import torch.nn as nn
from torch.utils.data import Dataset,DataLoader
from tqdm import tqdm
import matplotlib.pyplot as plt

# 1- 设置变量
# 运行设备
device = ("cuda" if torch.cuda.is_available() else "cpu")   # Windows的写法
# device = "mps"  # Mac的写法

# 数据文件路径
data_path = "../data/eng-fra-v2.txt"

# 句子开始标识
SOS_TOKEN = 0

# 句子结束标识
EOS_TOKEN = 1

# 句子长度规范:长度的上限
MAX_LENGTH = 10

# 2- 数据清洗
def normalizeString(line):
    # 将文本全部转小写
    line = line.lower().strip()
    # print(f"-{line}-")

    # 在指定的标点符号的前面增加空格
    line = re.sub(pattern=r"([.!?])",repl=r" \1",string=line)

    # 去除除了26个小写、.!?以外的其他符号,全部替换成空格
    line = re.sub(pattern=r"[^a-z.!?]+", repl=r" ", string=line)

    return line

# 3- 构建词汇表
def getdata():
    # 1- 读取文件
    with open(data_path,encoding="UTF-8",mode="r") as f:
        eng_fra_list = f.readlines()

    # 2- 划分得到英语和法语
    # eng_fra_pairs结构:[[英语句子1, 法语句子1], [英语句子2, 法语句子2]...]
    eng_fra_pairs = []
    for eng_fra_line in eng_fra_list:

        tmp_list = []
        for line in eng_fra_line.split("\t"):
            tmp_list.append(normalizeString(line))

        eng_fra_pairs.append(tmp_list)

    # 3- 分别得到英语词汇表和法语词汇表
    english_word2index = {"SOS":SOS_TOKEN,"EOS":EOS_TOKEN}
    english_word_cnt = 2

    franch_word2index = {"SOS":SOS_TOKEN,"EOS":EOS_TOKEN}
    franch_word_cnt = 2

    for eng_fra_list in eng_fra_pairs:
        # 英语句子处理:分词->去重->添加
        for eng_word in eng_fra_list[0].split(" "):
            if eng_word not in english_word2index:
                english_word2index[eng_word] = english_word_cnt
                english_word_cnt += 1

        # 法语句子处理:分词->去重->添加
        for fra_word in eng_fra_list[1].split(" "):
            if fra_word not in franch_word2index:
                franch_word2index[fra_word] = franch_word_cnt
                franch_word_cnt += 1

    # 4- 将法语字典中key和value的位置交换
    franch_index2word = {index:word for word,index in franch_word2index.items()}

    return english_word2index,english_word_cnt,franch_word2index,franch_word_cnt,eng_fra_pairs,franch_index2word

english_word2index,english_word_cnt,franch_word2index,franch_word_cnt,eng_fra_pairs,franch_index2word = getdata()

# 4- 自定义Dataset
class MyPairsDataset(Dataset):
    def __init__(self,eng_fra_pairs):
        self.eng_fra_pairs = eng_fra_pairs
        self.sample_cnt = len(eng_fra_pairs)    # 样本条数

    def __len__(self):
        return self.sample_cnt

    def __getitem__(self, index):
        # 1- 防止索引为负数;防止索引越界
        index = min(max(index,0), self.sample_cnt - 1)

        # 2- 获得英语句子(特征数据)和法语句子(目标值)
        # 2.1- 获得句子。[英语句子1, 法语句子1]
        eng_fra_pair = self.eng_fra_pairs[index]

        # 句子 -> 分词 -> 变成索引
        """
            为什么只在句子的末尾增加结束标识EOS_TOKEN,而不增加SOS_TOKEN?
            答:
                1- Seq2Seq架构要求必须要有EOS_TOKEN,而SOS_TOKEN可选的
                2- SOS_TOKEN会在Decoder中进行数据处理的时候再增加
                3- Decoder端我们只能通过添加SOS_TOKEN告诉解码器开始进行翻译的工作;以及通过添加EOS_TOKEN告诉解码器翻译工作结束
        """
        # 2.2- 处理英语句子
        x = [english_word2index[word] for word in eng_fra_pair[0].split(" ")]
        # 所有的张量数据必须放在同一个硬件设备上,要么同时在GPU,要么同时在CPU
        x_tensor = torch.tensor(x,dtype=torch.long,device=device)
        # print(f"原始英语句子{eng_fra_pair[0]},词索引向量{x_tensor}")

        # 2.3- 处理法语句子
        y = [franch_word2index[word] for word in eng_fra_pair[1].split(" ")]
        y.append(EOS_TOKEN)
        y_tensor = torch.tensor(y, dtype=torch.long, device=device)
        # print(f"原始法语句子{eng_fra_pair[1]},词索引向量{y_tensor}")

        return x_tensor,y_tensor

# 5- 创建DataLoader
def get_dataloader():
    # 1- 创建自定义Dataset实例对象
    dataset = MyPairsDataset(eng_fra_pairs)

    # 2- 创建数据加载器
    dataloader = DataLoader(dataset=dataset,batch_size=1,shuffle=True)

    return dataloader

# 6- 无注意力机制的编码器
class Encoder(nn.Module):
    def __init__(self,eng_vocab_size,input_size,hidden_size):
        """
        :param eng_vocab_size: 英语词汇表大小
        :param input_size: 词向量维度
        :param hidden_size: 隐藏状态向量维度
        """

        # 1- 初始化父类
        super().__init__()

        # 2- 设置属性值
        self.eng_vocab_size = eng_vocab_size
        self.input_size = input_size
        self.hidden_size = hidden_size

        # 3- 搭建网络结构
        # 3.1- 词嵌入层
        self.ebd = nn.Embedding(num_embeddings=self.eng_vocab_size,embedding_dim=self.input_size)

        # 3.2- 循环网络层
        self.gru = nn.GRU(input_size=self.input_size,hidden_size=self.hidden_size,num_layers=1,batch_first=True)

    def forward(self,input,hidden):
        """
        Encoder端的前向传播:负责理解英语句子的语义信息
        :param input: 本次输入数据,张量形状:[batch_size每个批次中英语句子的条数,seq_len英语句子中词的个数]
        :param hidden: 上一个时间步的隐藏状态,张量形状:[num_layers,batch_size,hidden_size]
        :return:
        """
        # 1- 调用词嵌入层
        # embed形状:[batch_size每个批次中英语句子的条数,seq_len英语句子中词的个数,input_size词向量维度]
        # print(f"1--input-->{input.shape}")
        embed = self.ebd(input)
        # print(f"2--embed-->{embed.shape}")

        # 2- 调用GRU
        output,hidden = self.gru(embed,hidden)

        return output,hidden

    def init_hidden(self):
        # 隐藏状态张量形状:[num_layers,batch_size,hidden_size]
        # num_layers:目前手动指定为1层
        # batch_size:因为我们没有对英语句子进行长度规范的处理,因此只能设置为1
        return torch.zeros(size=(1,1,self.hidden_size)).to(device=device)

# 测试编码器
def use_encoder():
    # 1- 准备数据
    dataloader = get_dataloader()

    # 2- 创建编码器对象
    my_encoder = Encoder(eng_vocab_size=english_word_cnt,input_size=256,hidden_size=256)
    my_encoder.to(device=device)

    # 3- 遍历数据,前向传播
    for x,y in dataloader:
        # 3.1- 初始隐藏状态
        hidden = my_encoder.init_hidden()

        # 3.2- 前向传播
        output,hidden = my_encoder(x,hidden)

        print(f"output-->{output.shape}")
        print(f"hidden-->{hidden.shape}")

        break

# 7- 解码器:无注意力机制
class Decoder(nn.Module):
    def __init__(self,fra_vocab_size,hidden_size):
        # 1- 初始化父类
        super().__init__()

        # 2- 设置属性值
        self.fra_vocab_size = fra_vocab_size
        self.hidden_size = hidden_size

        # 3- 搭建网络结构
        # 3.1- 词嵌入层
        self.ebd = nn.Embedding(num_embeddings=self.fra_vocab_size,embedding_dim=self.hidden_size)

        # 3.2- GRU层
        self.gru = nn.GRU(input_size=self.hidden_size,hidden_size=self.hidden_size,num_layers=1,batch_first=True)

        # 3.3- 输出层
        self.output_linear = nn.Linear(in_features=self.hidden_size,out_features=self.fra_vocab_size)

        # 4- 输出层的激活函数
        # CrossEntropyLoss = LogSoftmax + NLLLoss
        self.log_softmax = nn.LogSoftmax(dim=-1)

    def forward(self,input,hidden):
        """
        Decoder前向传播:负责翻译得到法语词
        :param input: 本次输入数据,每次只送一个法语词进来,张量形状:[batch_size,seq_len],[[一个法语词的词索引]]
        :param hidden: 上一个时间步的隐藏状态,张量形状:[num_layers,batch_size,hidden_size]
        :return:
        """

        # 1- 调用词嵌入层
        embed = torch.relu(self.ebd(input))

        # 2- 调用GRU
        output,hidden = self.gru(embed,hidden)
        print(f"1-->output-->{output.shape}")

        # 3- 调用输出层
        # 3.1- 将output由3维变成2维,[batch_size,seq_len,hidden_size] -> [batch_size,hidden_size]
        """
            output[:,-1,:] 针对文本分类的业务场景。-1表示取最后一个时间步的隐藏状态,文本分类需要对一整条句子的完整信息理解完以后再做分类
            output[:,0,:]  针对文本翻译的业务场景。
        """
        output = output[:,0,:]
        print(f"2-->output[:,0,:]-->{output.shape}")

        # 3.2- 调用
        output = self.log_softmax(self.output_linear(output))
        print(f"3-->output-->{output.shape}")

        return output,hidden

    def init_hidden(self):
        return torch.zeros(size=(1,1,self.hidden_size)).to(device=device)

# 测试解码器
def use_decoder():
    # 1- 准备数据
    dataloader = get_dataloader()

    # 2- 创建模型实例对象
    my_encoder = Encoder(eng_vocab_size=english_word_cnt,input_size=256,hidden_size=256).to(device=device)
    my_decoder = Decoder(fra_vocab_size=franch_word_cnt,hidden_size=256).to(device=device)

    # 3- 前向传播
    for x,y in dataloader:
        # x:一条英语句子,[[词索引1, 词索引2,... ]]
        # y:一条法语句子,[[词索引1, 词索引2,... ]]

        # 3.1- 调用编码器:让模型理解英语句子的完整含义
        h0 = my_encoder.init_hidden()
        encoder_output,hidden = my_encoder(x,h0)

        # 3.2- 调用解码器:每次送入一个词
        for index in range(y.shape[1]):
            # 3.3- 解码器前向传播
            fra_word = y[0][index].reshape(1,-1)
            decoder_output,hidden= my_decoder(fra_word, hidden)

            print(f"法语词词索引{fra_word},隐藏状态信息{hidden.shape}")

        break

# 8- 解码器:有注意力机制
class AttenDecoder(nn.Module):
    def __init__(self,fra_vocab_size,hidden_size):
        # 1- 初始化父类
        super().__init__()

        # 2- 设置属性值
        self.fra_vocab_size = fra_vocab_size
        self.hidden_size = hidden_size      # 有两个作用:1- 词向量维度;2- 隐藏状态向量维度

        # 3- 搭建网络结构
        # 3.1- 词嵌入层
        self.embedding = nn.Embedding(num_embeddings=self.fra_vocab_size,embedding_dim=self.hidden_size)

        # 3.2- Dropout随机失活层
        self.dropout = nn.Dropout(p=0.2)

        # 3.3- 线性层:用来计算QK的相似性
        self.attn = nn.Linear(in_features=self.hidden_size+self.hidden_size,out_features=MAX_LENGTH)

        # 3.4- 线性层:对Q和C拼接后的张量形状进行调整
        self.attn_combine = nn.Linear(in_features=self.hidden_size+self.hidden_size,out_features=self.hidden_size)

        # 3.5- GRU循环网络层
        self.gru = nn.GRU(input_size=self.hidden_size,hidden_size=self.hidden_size,num_layers=1,batch_first=True)

        # 3.6- 输出层
        self.out = nn.Linear(in_features=self.hidden_size,out_features=self.fra_vocab_size)

        # 激活函数
        self.log_softmax = nn.LogSoftmax(dim=-1)

    def forward(self, input, prev_hidden, encoder_outputs):
        """
        :param input: 解码器本次输入数据,也就是Q。张量形状 [batch_size,seq_len],每次只传一个词进来
        :param prev_hidden: 解码器上一个时间步的隐藏状态,也就是K。张量形状 [num_layers,batch_size,hidden_size]
        :param encoder_outputs: 编码器端所有时间步隐藏状态的拼接,也就是V,而且还做了句子长度规范的处理。张量形状 [batch_size,MAX_LENGTH,hidden_size]
        :return:
        """

        # 1- 调用词嵌入层
        embedded = self.dropout(self.embedding(input))

        # 2- 计算注意力机制
        # 2.1- Q和K拼接
        # embedded就是Q
        # 如果不进行张量形状调整,那么就是num_layers与batch_size进行拼接,业务层面是不合理。
        # 调整K的形状 由 [num_layers,batch_size,hidden_size] -> [batch_size,num_layers,hidden_size]
        K = prev_hidden.transpose(dim0=0,dim1=1)
        # 拼接
        qk_cat = torch.cat(tensors=(embedded,K), dim=-1)

        # 2.2- 通过线性层算QK的相似性
        score = self.attn(qk_cat)

        # 2.3- 相似性转成权重
        attn_weights = torch.softmax(score, dim=-1)

        # 2.4- 得到专属信息包C:权重矩阵和V进行矩阵乘法运算
        # C对应原理图中的attn_applied
        C = torch.bmm(attn_weights, encoder_outputs)

        # 2.5- Q和C拼接
        qc_cat = torch.cat(tensors=(embedded,C),dim=-1)

        # 2.6- 通过线性层调整QC拼接后的张量
        qc_output = torch.relu(self.attn_combine(qc_cat))

        # 3- 调用GRU
        output,hidden = self.gru(qc_output,prev_hidden)

        # 4- 调用输出层
        output = output[:,0,:]
        output = self.log_softmax(self.out(output))

        return output,hidden,attn_weights

    def init_hidden(self):
        return torch.zeros(size=(1,1,self.hidden_size)).to(device=device)

# 9- 模型训练
# 9.1- 单次训练过程
def train_iter(x,y,my_encoder,my_decoder,encoder_adam,decoder_adam,loss):
    """
    单次样本数据的训练过程
    :param x: 英语句子,张量形状[batch_size,seq_len]
    :param y: 法语句子,张量形状[batch_size,seq_len]
    :param my_encoder: 编码器对象
    :param my_decoder: 解码器对象
    :param encoder_adam: 编码器的优化器
    :param decoder_adam: 解码器的优化器
    :param loss: 损失函数对象
    :return: 平均损失值
    """

    # 1- 调用编码器
    encoder_hidden = my_encoder.init_hidden()
    encoder_output,encoder_hidden = my_encoder(x,encoder_hidden)

    # 2- 调用解码器之前的准备工作
    # 2.1- 句子长度规范化处理,同时也是合并得到V
    # 初始化一个形状[1,10,256]的全0张量
    V = torch.zeros(size=(1,MAX_LENGTH,256), device=device)
    copy_seq_len = min(MAX_LENGTH, x.shape[1])
    V[:, :copy_seq_len, :] = encoder_output[:, :copy_seq_len, :]

    # 2.2- 准备变量
    # 将编码器端最后一个时间步的隐藏状态作为 初始隐藏状态 给到解码器使用
    decoder_hidden = encoder_hidden
    # 损失值
    loss_value = 0.0
    # 获得法语句子中词的个数
    y_seq_len = y.shape[1]
    # 存储法语翻译结果,而且上个时间步的预测结果会作为本次输入数据使用,这称之为自回归
    input_y = torch.tensor([[SOS_TOKEN]], device=device)
    # 教师机制的标识。True:使用教师机制,也就是告诉下个时间步【真实】的法语词内容。反之,把【预测】结果给到下个时间步
    teacher_forcing_flag = True if random.random()<0.5 else False

    # 3- 调用解码器
    if teacher_forcing_flag:
        # 使用教师机制,也就是告诉下个时间步【真实】的法语词内容
        for i in range(y_seq_len):
            # 3.1- 前向传播
            pred_output,decoder_hidden,attn_weights = my_decoder(input=input_y, prev_hidden=decoder_hidden, encoder_outputs=V)

            # 3.2- 计算损失值
            # print(f"y-->{y.shape}")
            # print(f"y[0]-->{y[0].shape}")
            # print(f"y[0][i]-->{y[0][i]}")

            y_true = y[0][i].reshape(1)

            # print(f"y_true-->{y_true.shape}")

            loss_value += loss(pred_output,y_true)

            # 3.3- 告诉下个时间步【真实】的法语词内容
            input_y = y[0][i].reshape(1,-1) # [batch_size,seq_len]

    else:
        # 普通训练机制,把【预测】结果给到下个时间步
        for i in range(y_seq_len):
            # 3.1- 前向传播
            pred_output, decoder_hidden, attn_weights = my_decoder(input=input_y, prev_hidden=decoder_hidden,
                                                                   encoder_outputs=V)

            # 3.2- 计算损失值
            y_true = y[0][i].reshape(1)
            loss_value += loss(pred_output, y_true)

            # 3.3- 获得预测结果的词索引
            pred_word_index = torch.argmax(pred_output, dim=-1)

            # 3.4- 判断是否到了句子的结尾
            if pred_word_index == EOS_TOKEN:
                break

            # 3.5- 告诉下个时间步【预测】的法语词内容
            input_y = pred_word_index.reshape(1, -1)  # [batch_size,seq_len]

    # 4- 反向传播固定代码
    encoder_adam.zero_grad()
    decoder_adam.zero_grad()

    loss_value.sum().backward()

    encoder_adam.step()
    decoder_adam.step()

    return loss_value.item() / y_seq_len

# 9.2- 整体训练过程
def train():
    # 1- 准备数据
    dataloader = get_dataloader()

    # 2- 创建类的实例对象
    # 2.1- 模型实例对象
    my_encoder = Encoder(eng_vocab_size=english_word_cnt,input_size=256,hidden_size=256).to(device=device)
    my_decoder = AttenDecoder(fra_vocab_size=franch_word_cnt,hidden_size=256).to(device=device)
    # 2.2- 优化器实例对象:与具体的实例对象有关,因此要创建单独的两个
    encoder_adam = torch.optim.Adam(params=my_encoder.parameters(),lr=1e-4)
    decoder_adam = torch.optim.Adam(params=my_decoder.parameters(),lr=1e-4)
    # 2.3- 损失函数对象:与具体的实例对象无关,内部只是通过预测和真实值计算差值而已,因此只需要创建一个
    loss = nn.NLLLoss()

    # 3- 设置模式
    my_encoder.train()
    my_decoder.train()

    # 4- 模型训练
    epochs = 1

    avg_loss_list = []  # 用来存储平均损失

    for epoch in range(epochs):
        total_loss_value = 0.0  # 总损失值

        for i,(x_train,y_train) in enumerate(tqdm(dataloader), start=1):
            # 1- 单次训练得到损失值
            loss_value = train_iter(x_train,y_train,my_encoder,my_decoder,encoder_adam,decoder_adam,loss)

            # 2- 更新统计指标
            total_loss_value += loss_value

            # 3- 每隔100个批次记录并且输出一次信息
            if i%100==0:
                # 计算平均损失值
                avg_loss = total_loss_value / i

                avg_loss_list.append(avg_loss)
                print(f"第{epoch+1}轮次,平均损失{avg_loss}")

    # 5- 保存训练好的模型
    torch.save(my_encoder.state_dict(), "model/encoder.pkl")
    torch.save(my_decoder.state_dict(), "model/decoder.pkl")

    # 6- 绘制损失值变化曲线
    plt.plot(avg_loss_list)
    plt.show()

# 10- 模型预测
# 10.1- 单条句子的翻译
def seq2seq_evaluate(x_tensor,my_encoder,my_decoder):
    """
    单条句子的翻译
    :param x_tensor: 一条处理后的英语句子,形状[batch_size,seq_len]
    :param my_encoder: 编码器
    :param my_decoder: 解码器
    :return: 翻译的法语词列表
    """

    with torch.no_grad():
        # 1- 调用编码器
        encoder_output,encoder_hidden = my_encoder(x_tensor,my_encoder.init_hidden())

        # 2- 调用解码器之前的准备工作
        # 2.1- 句子长度规范化处理,同时也是合并得到V
        # 初始化一个形状[1,10,256]的全0张量
        V = torch.zeros(size=(1,MAX_LENGTH,256), device=device)
        copy_seq_len = min(MAX_LENGTH, x_tensor.shape[1])
        V[:, :copy_seq_len, :] = encoder_output[:, :copy_seq_len, :]

        # 2.2- 准备变量
        # 将编码器端最后一个时间步的隐藏状态作为 初始隐藏状态 给到解码器使用
        decoder_hidden = encoder_hidden
        # 存储单次的法语翻译结果,而且上个时间步的预测结果会作为本次输入数据使用,这称之为自回归
        input_y = torch.tensor([[SOS_TOKEN]], device=device)
        # 存储所有的翻译法语词
        franch_word_list = []

        # 3- 评估/预测
        for i in range(MAX_LENGTH):
            # 3.1- 前向传播:调用解码器
            decoder_output,decoder_hidden,attn_weights = my_decoder(input=input_y, prev_hidden=decoder_hidden, encoder_outputs=V)

            # 3.2- 获得预测概率最高的那个词的词索引
            pred_fra_index = torch.argmax(decoder_output, dim=-1).item()

            # 3.3- 判断是否遇到了句子的结束标识
            if pred_fra_index == EOS_TOKEN:
                franch_word_list.append("EOS")
                break
            else:
                # 通过索引获得法语词的内容
                fra_word = franch_index2word[pred_fra_index]
                franch_word_list.append(fra_word)

            input_y = torch.tensor([[pred_fra_index]], device=device)

    return franch_word_list

# 10.2- 完整的预测流程
def use_seq2seq_evaluate():
    # 1- 手动准备未知数据
    my_samplepairs = [
        ['i m impressed with your french .', 'je suis impressionne par votre francais .'],
        ['i m more than a friend .', 'je suis plus qu une amie .'],
        ['she is beautiful like her mother .', 'elle est belle comme sa mere .']
    ]

    # 2- 加载训练好的模型
    my_encoder = Encoder(eng_vocab_size=english_word_cnt,input_size=256,hidden_size=256).to(device=device)
    my_encoder.load_state_dict(torch.load("model/encoder.pkl"))

    my_decoder = AttenDecoder(fra_vocab_size=franch_word_cnt,hidden_size=256).to(device=device)
    my_decoder.load_state_dict(torch.load("model/decoder.pkl"))

    # 3- 设置模式
    my_encoder.eval()
    my_decoder.eval()

    # 4- 预测
    for i,pair in enumerate(my_samplepairs):
        x = pair[0] # 英语句子
        y = pair[1] # 英语句子

        # 4.1- 英语句子转成张量
        x_list = [english_word2index[word] for word in x.split(" ")]
        # 所有的张量数据必须放在同一个硬件设备上,要么同时在GPU,要么同时在CPU
        x_tensor = torch.tensor(x_list, dtype=torch.long, device=device).reshape(1,-1)

        # 4.2- 对单条句子进行评估
        franch_word_list = seq2seq_evaluate(x_tensor,my_encoder,my_decoder)

        # 4.3- 输出结果
        print(f"英语句子 {x},真实法语句子 {y},翻译法语句子 {' '.join(franch_word_list)}")

if __name__ == '__main__':
    # 1- 数据清洗测试
    # content = " I LOve h@@@eima! "
    # normalizeString(content)

    # 2- 读取文件
    # getdata()

    # print(english_word_cnt)
    # print(franch_word_cnt)
    # print(eng_fra_pairs[:5])
    # print(english_word2index)
    # print(franch_word2index)

    # 3- 测试Dataloader
    # dataloader = get_dataloader()
    # for x,y in dataloader:
    #     print(x)
    #     print(y)
    #
    #     break

    # 4- 测试编码器
    # use_encoder()

    # use_decoder()


    # 5- 模型训练
    train()

    # 6- 模型预测
    use_seq2seq_evaluate()