06. Seq2Seq模型

一、什么是 Seq2Seq 模型

  传统的自然语言处理任务(如文本分类、序列标注)以静态输出为主,其目标是预测固定类别或标签。然而,现实中的许多应用需要模型动态生成新的序列,例如:

  • 机器翻译:输入中文句子,输出对应的英文翻译。
  • 文本摘要:输入长篇文章,生成简短的摘要。
  • 问答系统:输入用户问题,生成自然语言回答。
  • 对话系统:输入对话历史,生成连贯的写一条回复。

  这些任务具有两个关键共同点:

  • 输入和输出均为序列(如词、字符或字词序列)。
  • 输入与输出序列长度动态可变(例如翻译任务中,中英文句子长句可能不同)

  为了解决这类问题,研究者提供了 Seq2Seq(Sequence to Sequence,序列到序列)模型。Seq2Seq 模型由一个 编码器(Encoder)和 解码器(Decoder)构成。

  • 编码器 主要由一个 循环神经网络(RNN / LSTM / GRU)构成,其任务是 将输入序列的语义信息提取并压缩为一个上下文向量。
  • 解码器 主要也由一个 循环神经网络(RNN / LSTM / GRU)构成,其任务是 基于编码器传递的上下文向量,逐步生成目标序列。

Seq2Seq模型结构

  我们在终端中输入 pip 指令安装 PyTorch。默认是从国外的主站上下载,因此,我们可能会遇到网络不好的情况导致下载失败。我们可以在 pip 指令后通过 -f 指定国内镜像源下载。

pip install torch torchvision -f https://mirrors.aliyun.com/pytorch-wheels/cu132

  国内常用的 pip 下载源列表:

  安装完 PyTorch 之后,我们可以通过如下代码查看 CUDA 是否可用:

import torch

print(f"PyTorch的版本:{torch.__version__}")
print(f"CUDA是否可用:{torch.cuda.is_available()}")

如果输出 CUDA 不可用,有可能你使用国内镜像只同步 PyPI 主站,不包含 PyTorch 官方 CUDA 专用包,pip 只会找到 CPU 版,找不到 CUDA 版。因此,我们还需要使用 --extra-index-url https://download.pytorch.org/whl/cu132 补充官方 CUDA 源 。

二、模型的训练和推理机制

2.1、训练机制

  Seq2Seq 模型的训练目标,是在给定输入序列的条件下,逐步生成完整且准确的目标序列。下面以一个中 – 英机器翻译样本为例,说明训练过程的各个环节。这里,我们假设某个训练样本为:

  • 中文输入:我喜欢你。
  • 英文输出:I like you.

【1】、数据准备

  为了让模型明确目标序列的起点和终点,我们通常在目标句前添加 <sos>(start of sequence),句末添加 <eos>(end of sequence)两个特殊标记。这两个特殊标记帮助模型学会从哪里开始生成,以及何时停止生成。

"I like you." → "<sos> I like you. <eos>"

【2】、前向传播

  在模型处理输入序列时,编码器 的 循环神经网络 会依次接受每个 token 的输入,并在每个时间步更新隐藏状态。每个隐藏状态都携带了截止到当前位置为止的信息。

  随着序列推进,信息不断积累,最终会 在最后一个时间步生成一个包含整句信息的隐藏状态。最后这个隐藏状态就会作为 上下文向量,传递给 解码器,用于知道后续的序列生成。

Sqe2Seq模型的编码器

  在生成开始时,解码器 的 循环神经网络 以 上下文向量 作为 初始隐藏状态,并接受一个特殊的 起始标记(<sos>,start of sentence)作为 第一个时间步的输入,用于 预测第一个 token。

  随后,在每一个时间步,模型都会把 前一时刻的目标序列中真实的 token 作为为下一步输入,这种策略叫做 Teacher Forcing 策略。

  生成过程会持续进行,直到模型生成了一个特殊的 结束标记(<eos>,end of sentence),表示句子生成完成。

Seq2Seq模型训练过程解码器的工作策略

  使用 Teacher Forcing 策略带来了两个明显好处:

  • 训练更快,误差不会累积。
  • 梯度传播更稳定,有利于优化收敛。

为了增强编码器的理解能力,循环网络也可以采用 双向结构(结合前文和后文信息)或 多层结构(提取更深的语义信息)。

【3】、计算损失

  解码器每一步输出一个 token 的概率分布,我们通过交叉熵损失函数衡量模型对真实词的预测质量。训练过程中,每一个时间步都会产生一个损失值。该样本的总损失,就是所有时间步的损失值逐步累加的结果。

计算损失

【4】、反向传播

  在 PyTorch 中,我们可以调用 loss.backward() 即可自动完成梯度的反向传播。系统会沿时间维度展开计算图,自动完成所有参数的梯度计算,无需手动推导,实现简洁高效。

2.2、推理机制

【1】、编码器处理

  推理阶段的编码器处理流程与训练时完全一致。输入序列会经过分词、嵌入和循环神经网络的逐步处理,最终生成一个表示整句语义的上下文向量,该向量将作为解码器的初始隐藏状态,为生成过程提供语义基础。

【2】、解码器处理

  解码器是推理过程的核心,其生成方式采用 自回归生成(Autoregressive Generation):每一步的输出会作为下一步的输入,逐步构造完整句子。

Seq2Seq模型推理过程解码器的工作策略

  自回归生成流程如下:

  1. 解码器接收起始标记 <sos>,生成第一个词。
  2. 将上一步生成的词作为当前输入,再预测第二个词。
  3. 持续重复以上过程,直到模型生成 <eos>,或达到设定的最大生成步数。

  每个时间步,解码器输出的是一个 词概率分布。我们需要从中选择一个具体词作为本时间步的输出,选择方式即为生成策略。常见策略包括:

  • 贪心解码(Greedy Decoding):每一步都选择概率最高的词。它的过程简单高效,但容易陷入局部最优,生成不够多样。
  • 束搜索(Beam Search):每一步保留多个候选词序列(如 beam size = 3),并在扩展后选择得分最高的完整句子。它基于全局考虑,生成质量高,但计算开销大。

起始标记和结束标记会在训练数据中显式添加,模型会在训练中学会何时开始、如何续写,以及何时结束,从而掌握完整的生成流程。

三、Seq2Seq 存在的问题

  在上述 Seq2Seq 模型中,编码器会将整个源句压缩为一个固定长度的上下文向量,并将其作为解码器生成目标序列的唯一参考。这种 “压缩再解压” 的方式虽然结构简洁,但在实际任务中暴露出两个核心问题:

【1】、信息压缩困难,语义表达受限

  对于编码器而言,用一个定长向量去表达任意复杂的句子,是一项非常困难的任务。尤其在面对长句时,信息很容易在压缩过程中丢失,导致语义表达不完整。这种 “信息瓶颈” 限制了模型在处理长文本或复杂语义结构时的表现。

【2】、缺乏动态感知,解码难以精准生成

  解码器始终只能基于同一个上下文向量进行生成。但在实际生成过程中,不同位置的目标词,往往依赖源句中不同的关键信息:

  • 生成主语时,可能更依赖源句的开头。
  • 生成谓语或宾语时,可能需要参考句中或句末内容。

  然而在固定表示下,解码器无法 “有选择地关注” 输入序列的不同部分,只能一视同仁地处理所有信息,从而降低了生成的准确性与灵活性。

四、中英翻译案例

  我们可以从 阿里天池 中下载 中英翻译的数据集: https://tianchi.aliyun.com/dataset/174937 。

  我们可以在终端中使用 pip 安装 pandas、sklearn 机器学习库和 ntlk 自然语言处理工具包。默认是从国外的主站上下载,因此,我们可能会遇到网络不好的情况导致下载失败。我们可以在 pip 指令后通过 -i 指定国内镜像源下载。

pip install scikit-learn nltk -i https://pypi.tuna.tsinghua.edu.cn/simple

  数据预处理:

import os

DATA_DRIECTORY = os.path.join(os.getcwd(), "assets", "data")
MODEL_DRIECTORY = os.path.join(os.getcwd(), "assets", "models")

UNK_TOKEN = "<unk>"
PAD_TOKEN = "<pad>"
SOS_TOKEN = "<sos>"
EOS_TOKEN = "<eos>"

SPECIAL_TOKENS = [UNK_TOKEN, PAD_TOKEN, SOS_TOKEN, EOS_TOKEN]
import os

import pandas as pd

from nltk.tokenize import TreebankWordTokenizer

from sklearn.model_selection import train_test_split

def preprocess_data(file_path, test_size):
    # 读取 TSV 格式的文件,
    # sep 指定字段的分隔符,head=None 表示没有表头, 
    # secols 为要使用的列,可以使用索引,names 表示为列取的标签名
    df = pd.read_csv(file_path, sep="\t", header=None, usecols=[0, 1], names=["en", "zh_cn"], encoding="utf-8").dropna()

    train_df, test_df = train_test_split(df, test_size=test_size)               # 划分训练集与测试集

    # 构建中文词表,这里采用按字符切分
    zh_cn_vacab_set = set([vacab for sentence in train_df["zh_cn"] for vacab in sentence if vacab.strip()])
    zh_cn_vacab_list = SPECIAL_TOKENS + list(zh_cn_vacab_set)                   # 添加特殊标记
    print("构建中文词表成功")

    # 构建英文词表,基于正则匹配规则,将输入的英文句子拆分成包含单词和独立标点符号的词元列表(Token List)
    tokenizer = TreebankWordTokenizer()

    en_vacab_set = set([vacab for sentence in train_df["en"] for vacab in tokenizer.tokenize(sentence) if vacab.strip()])
    en_vacab_list = SPECIAL_TOKENS + list(en_vacab_set)                         # 添加特殊标记
    print("构建英文词表成功")

    if not os.path.exists(DATA_DRIECTORY):
        os.makedirs(DATA_DRIECTORY)

    # 保存词表到文件中
    file_path = os.path.join(DATA_DRIECTORY, "zh_cn_vocab.txt")
    with open(file_path, "w", encoding="utf-8") as f:
        f.write("\n".join(zh_cn_vacab_list))

    print("保存中文词表到文件中成功")

    file_path = os.path.join(DATA_DRIECTORY, "en_vocab.txt")
    with open(file_path, "w", encoding="utf-8") as f:
        f.write("\n".join(en_vacab_list)) 

    print("保存英文词表到文件中成功")

    # 构建词表到索引的映射
    zh_cn_word2index = {word: index for index, word in enumerate(zh_cn_vacab_list)}
    print("构建中文词表到索引的映射成功")

    en_word2index = {word: index for index, word in enumerate(en_vacab_list)}
    print("构建英文词表到索引的映射成功")

    # 构建训练集
    train_df["zh_cn"] = train_df["zh_cn"].apply(lambda sentence: get_word2index(zh_cn_word2index, list(sentence)))
    train_df["en"] = train_df["en"].apply(lambda sentence: get_word2index(en_word2index, tokenizer.tokenize(sentence), True))

    file_path = os.path.join(DATA_DRIECTORY, "translation_train.jsonl")
    train_df.to_json(file_path, orient="records", lines=True)                   # 保存训练集
    print("保存训练集数据成功")

    # 构建测试集
    test_df["zh_cn"] = test_df["zh_cn"].apply(lambda sentence: get_word2index(zh_cn_word2index, list(sentence)))
    test_df["en"] = test_df["en"].apply(lambda sentence: get_word2index(en_word2index, tokenizer.tokenize(sentence), True))

    # 保存测试集
    file_path = os.path.join(DATA_DRIECTORY, "translation_test.jsonl")
    test_df.to_json(file_path, orient="records", lines=True)
    print("保存测试集数据成功")

def get_word2index(word2index, tokens, add_sos_eos=False):
    if add_sos_eos:
        tokens = [SOS_TOKEN] + tokens + [EOS_TOKEN]
    return [word2index.get(token, SPECIAL_TOKENS.index(UNK_TOKEN)) for token in tokens]

if __name__ == "__main__":
    test_size = 0.2
    file_path = os.path.join(DATA_DRIECTORY, "cmn.txt")

    preprocess_data(file_path, test_size)

  创建数据集:

import torch
import pandas as pd

from torch.utils.data import Dataset

class TranslationDataset(Dataset):
    def __init__(self, file_path):
        self.data = pd.read_json(file_path, orient="records", lines=True).to_dict(orient="records")

    def __len__(self):
        return len(self.data)

    def __getitem__(self, index):
        index = min(max(index, 0), len(self.data) - 1)                          # 索引边界检查

        # x.shape: [源序列长度]
        x = torch.tensor(self.data[index]["zh_cn"], dtype=torch.long)

        # y.shape: [目标序列长度]
        y = torch.tensor(self.data[index]["en"], dtype=torch.long)
        
        return x, y

  创建模型:

import torch

from torch import nn

class TranslationEncoder(nn.Module):
    def __init__(self, vocab_size, embedding_dim, hidden_dim, num_layers, padding_idx):
        super().__init__()

        # 词嵌入层 [词向量数(词表大小), 词向量维度, 填充值的索引]
        self.embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx)
        # GRU 层 [输入维度(词向量维度), 隐藏层维度, 隐藏层层数, 是否将batch作为输入张量的第一个维度]
        self.gru = nn.GRU(embedding_dim, hidden_dim, num_layers, batch_first=True)

    def forward(self, x):
        # x.shape: [批量大小, 序列长度]
        # embed.shape: [批量大小, 序列长度, 词向量维度]
        embed = self.embedding(x)                                               # 获取单词的嵌入向量

        # output 最后一层每个时间步的隐藏层状态
        # output.shape: [批量大小, 序列长度, 隐藏层维度]
        # hn 最后一个时间步的隐藏层状态(包含每一个层,每一个方向)
        # hn.shape: [隐藏层层数, 批量大小, 隐藏层维度]
        output, hn = self.gru(embed)

        lengths = (x != self.embedding.padding_idx).sum(dim=1)                  # 获取每个样本真实token的长度

        # last_hidden_state.shape: [批量大小, 隐藏层维度]
        last_hidden_state = output[torch.arange(output.shape[0]), lengths - 1]  # 获取每个样本最后一个时间步隐藏状态

        return output, last_hidden_state

class TranslationDecoder(nn.Module):
    def __init__(self, vocab_size, embedding_dim, hidden_dim, num_layers, padding_idx):
        super().__init__()

        # 词嵌入层 [词表的大小, 词向量维度, 填充值的索引]
        self.embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx)
        # GRU 层 [输入维度(词向量维度), 隐藏层维度, 隐藏层层数, 是否将batch作为输入张量的第一个维度]
        self.gru = nn.GRU(embedding_dim, hidden_dim, num_layers, batch_first=True)
        # 输出层 [输入维度(隐藏层维度), 输出维度(英文词表大小)]
        self.liner = nn.Linear(hidden_dim, vocab_size)

    def forward(self, x, hx):
        # x.shape: [批量大小, 序列长度(1)]
        # hx.shape: [隐藏层层数(1), 批量大小, 隐藏层维度]
        # embed.shape: [批量大小, 序列长度(1), 词向量维度]
        embed = self.embedding(x)                                               # 获取单词的嵌入向量

        # output 最后一层每个时间步的隐藏层状态
        # output.shape: [批量大小, 序列长度(1), 隐藏层维度]
        # hn 最后一个时间步的隐藏层状态(包含每一个层,每一个方向)
        # hn.shape: [隐藏层层数, 批量大小, 隐藏层维度]
        output, hn = self.gru(embed, hx)

        # Liner 层保留输入张量的所有维度(除最后一维外全部不变),仅将最后一维(特征维度)替换为 out_features
        # output.shape: [批量大小, 序列长度(1), 输出维度(英文词表大小)]
        output = self.liner(output)
        
        return output, hn

class TranslationModel(nn.Module):
    def __init__(self, vocab_sizes, embedding_dim, hidden_dim, num_layers, padding_idxs):
        super().__init__()

        self.encoder = TranslationEncoder(vocab_sizes[0], embedding_dim, hidden_dim, num_layers, padding_idxs[0])
        self.decoder = TranslationDecoder(vocab_sizes[1], embedding_dim, hidden_dim, num_layers, padding_idxs[1])

  模型单轮训练:

def model_train_one_epoch(model, device, data_loader, criterion, optimizer):
    model.train()                                                               # 训练模式
    total_loss = 0.0

    for x, y in data_loader:
        # x.shape: [批量大小, 源序列长度]
        # y.shape: [批量大小, 目标序列长度]
        x, y = x.to(device), y.to(device)                                       # 指定设备

        # 编码阶段
        # context_vector.shape: [批量大小, 隐藏层维度]
        encoder_outputs, context_vector = model.encoder(x)

        # 解码阶段
        decoder_inputs = y[:, : -1]                                             # 获取解码器的输入列表
        decoder_targets = y[:, 1 :]                                             # 获取解码器的目标列表

        # unsqueeze(dim) 方法在指定位置插入一个大小为 1 的新维度
        # decoder_hidden.shape: [1(隐藏层层数), 批量大小, 隐藏层维度]
        decoder_hidden = context_vector.unsqueeze(0)                            # 获取解码器的初始状态
        decoder_outputs = []                                                    # 解码器的输出列表

        seq_len = decoder_inputs.shape[1]                                       # 获取解码器的输入序列长度

        for i in range(seq_len):
            # decoder_inputs[:, i].shape: [批量大小]
            # decoder_input.shape: [批量大小, 1(序列长度)]
            decoder_input = decoder_inputs[:, i].unsqueeze(1)                   # 获取当前时间步的输入

            # decoder_output.shape: [批量大小, 序列长度(1), 类别数(词汇表大小)]
            decoder_output, decoder_hidden = model.decoder(decoder_input, decoder_hidden)
            decoder_outputs.append(decoder_output)

        # 计算损失
        # decoder_targets.shape: [批量大小, 序列长度] -> [批量大小 * 序列长度]
        decoder_targets = decoder_targets.reshape(-1)

        # torch.cat() 就是把多个张量沿指定维度 "粘" 在一起,拼接后该维度的长度 = 各张量该维度长度之和,其它维度保持不变
        # decoder_outputs: [tensor([批量大小, 序列长度(1), 类别数(词汇表大小)])] -> [批量大小, 各个序列长度维度长度之和, 类别数(词汇表大小)]
        decoder_outputs = torch.cat(decoder_outputs, dim=1)                     # 将多个张量按指定维度拼接

        # decoder_outputs.shape:[批量大小 * 序列长度, 类别数(词汇表大小)]
        decoder_outputs = decoder_outputs.reshape(-1, decoder_outputs.shape[-1])

        # CrossEntropyLoss() 多分类损失函数
        # 如果目标值(y)的形状为 N,则预测值(output)的形状为 [N, 类别数]
        loss = criterion(decoder_outputs, decoder_targets)                      # 计算损失

        optimizer.zero_grad()                                                   # 清空梯度
        loss.backward()                                                         # 反向传播
        optimizer.step()                                                        # 优化器更新参数

        # x.shape[0] 是当前批次的样本数量
        # 乘以 x.shape[0] 是为了消除批次大小不一致(尤其是最后一个不完整批次)带来的统计偏差
        total_loss += loss.item() * x.shape[0]                                  # 计算当前批次的损失

    # len(dataloader.dataset) 返回数据集的样本数量
    # len(dataloader) 返回数据加载器的批次数量
    return total_loss / len(data_loader.dataset)                                # 返回平均损失

  训练模型:

EMBEDDING_DIM = 128
HIDDEN_DIM = 256
NUM_LAYERS = 1

BATCH_SIZE = 128

LEARNING_RATE = 1e-3
EPOCHS = 10
from torch.nn.utils.rnn import pad_sequence

def collate_fn(batch):
    # batch 是一个二元组列表,[(x_tensor, y_tensor)]
    x_tensors = [item[0] for item in batch]
    y_tensors = [item[1] for item in batch]

    # pad_sequence() 方法将多个长度不同的张量(序列)填充到相同长度,并堆叠成一个批处理张量
    # padding_value 填充的值,<pad> 特殊标识的索引
    x_tensors = pad_sequence(x_tensors, batch_first=True, padding_value=SPECIAL_TOKENS.index(PAD_TOKEN))
    y_tensors = pad_sequence(y_tensors, batch_first=True, padding_value=SPECIAL_TOKENS.index(PAD_TOKEN))

    return x_tensors, y_tensors
import os

import torch

from torch.utils.data import DataLoader

def model_train(model, device, data_loader, learning_rate, epochs, pad_token_index):
    model.to(device)                                                            # 指定设备

    # ignore_index 让损失函数忽略指定的目标类别索引,不参与损失计算
    criterion = torch.nn.CrossEntropyLoss(ignore_index=pad_token_index)         # 多元交叉熵损失函数
    optimizer = torch.optim.Adam(model.parameters(), learning_rate)             # 创建优化器

    # 开始训练
    for epoch in range(epochs):
        print(f"开始第 {epoch + 1} 次训练")
        loss = model_train_one_epoch(model, device, data_loader, criterion, optimizer)
        print(f"epoch: {epoch + 1:0>2},loss: {loss:.4f}")
        
    print("训练完成,开始保存模型")
    
    if not os.path.exists(MODEL_DRIECTORY):
        os.makedirs(MODEL_DRIECTORY)

    file_path = os.path.join(MODEL_DRIECTORY, "translation_model_by_seq2seq.pth")
    torch.save(model.state_dict(), file_path)                                   # 保存模型

    print("模型保存完成")

if __name__ == "__main__":
    pad_token_index = SPECIAL_TOKENS.index(PAD_TOKEN)
    padding_idxs = [pad_token_index] * 2
    
    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

    # 加载词表
    file_path = os.path.join(DATA_DRIECTORY, "zh_cn_vocab.txt")
    with open(file_path, "r", encoding="utf-8") as f:
        zh_cn_vocab_list = [line.strip() for line in f.readlines()]

    print("加载中文词表完成")

    file_path = os.path.join(DATA_DRIECTORY, "en_vocab.txt")
    with open(file_path, "r", encoding="utf-8") as f:
        en_vocab_list = [line.strip() for line in f.readlines()]

    vocab_sizes = [len(zh_cn_vocab_list), len(en_vocab_list)]

    print("加载英文词表完成")

    file_path = os.path.join(DATA_DRIECTORY, "translation_train.jsonl")
    train_dataset = TranslationDataset(file_path)                               # 创建训练数据集
    print("创建训练集成功")

    # collate_fn 用来指定自定义的填充方式,如果一批数据中的序列长度不一样,会报错
    train_data_loader = DataLoader(train_dataset, BATCH_SIZE, True, collate_fn=collate_fn)  # 创建训练数据集加载器
    print("创建训练数据集加载器成功")

    model = TranslationModel(vocab_sizes, EMBEDDING_DIM, HIDDEN_DIM, NUM_LAYERS, padding_idxs)  # 模型
    print("创建模型成功")

    model_train(model, device, train_data_loader, LEARNING_RATE, EPOCHS, pad_token_index)

  在机器翻译任务中,BLEU(Bilingual Evaluation Understudy) 是一种常用的自动评估指标,用于衡量模型生成的翻译与人工参考译文之间的相似程度。其核心思想是:

  • n-gram 匹配:统计预测译文中有多少 n-gram(词或短语)同时出现在参考译文中,用于衡量翻译内容的准确性。
  • 精确率计算:将匹配到的 n-gram 数量除以预测译文中 n-gram 的总数,反映生成译文中 “正确部分” 的比例。

  此外,BLEU 还引入长度惩罚机制,防止模型通过生成过短句子获得不合理的高分。最终得到的 BLEU 分数越高,说明生成译文与参考译文越接近。

  在本案例中,我们可以使用 Python 的 NLTK 库 中的 bleu_score 模块,对模型在测试集上的翻译结果进行评估,主要参考 BLEU-4 的得分情况,作为翻译质量的衡量依据。

  模型单批次预测:

import torch

def model_predict_one_batch(model, device, x, en_word2index, max_seq_len):
    model.eval()                                                                # 评估模式

    generated_token_indexes = []                                                # 存储每个时间步的预测概率最大的单词索引

    with torch.no_grad():                                                       # 禁用梯度计算
        # 编码阶段
        # context_vector.shape: [批量大小, 隐藏层维度]
        encoder_outputs, context_vector = model.encoder(x)

        # 解码阶段
        # decoder_hidden.shape: [1(隐藏层层数), 批量大小, 隐藏层维度]
        decoder_hidden = context_vector.unsqueeze(0)                            # 初始隐藏状态
        batch_size = x.size(0)                                                  # 批量大小

        # decoder_input.shape: [批量大小, 序列长度(1)]
        # torch.full(张量, 值) 创建一个张量,所有元素都填充为指定值
        decoder_input = torch.full((batch_size, 1), en_word2index[SOS_TOKEN], dtype=torch.long, device=device)

        is_finished = torch.full([batch_size], False, device=device)            # 记录每个样本是否生成完成

        # 自回归生成
        for i in range(max_seq_len):
            # decoder_output.shape: [批量大小, 序列长度(1), 词汇表大小]
            decoder_output, decoder_hidden = model.decoder(decoder_input, decoder_hidden)   # 解码器前向传播

            # next_token_indexes.shape: [批量大小, 序列长度(1)]
            next_token_indexes = decoder_output.argmax(dim=-1)                  # 获取每个时间步的预测概率最大的单词索引

            generated_token_indexes.append(next_token_indexes)                  # 存储每个时间步的预测概率最大的单词索引
            decoder_input = next_token_indexes                                  # 更新解码器的输入

            # 判断是否生成了 <eos> 标记
            is_finished |= (next_token_indexes.squeeze(1) == en_word2index[EOS_TOKEN])

            # 如果所有样本都生成完成,跳出循环
            if is_finished.all():
                break

        # 处理预测结果
        # generated_tensor.shape: [批量大小, 各个序列长度维度长度之和]
        generated_tensor = torch.cat(generated_token_indexes, dim=1)            # 合并所有时间步的预测概率最大的单词索引
        generated_list = generated_tensor.tolist()

        # 去掉 <eos> 标记之后的 token id
        for index, sentence in enumerate(generated_list):
            if en_word2index[EOS_TOKEN] in sentence:
                generated_list[index] = sentence[:sentence.index(en_word2index[EOS_TOKEN])]

    return generated_list

  模型评估:

MAX_SEQ_LEN = 100
import os

import torch

from torch.utils.data import DataLoader

from nltk.translate.bleu_score import corpus_bleu

def model_evaluate(model, device, data_loader, en_word2index, max_seq_len):
    model.to(device)                                                            # 指定设备
    model.eval()                                                                # 评估模式

    # predictions: [[*, *, *], [*, *, *]]
    predictions = []
    # references: [[[*, *, *]], [[*, *, *]]]
    references = []

    for x, y in data_loader:
        # x.shape: [批量大小, 源序列长度]
        # y.shape: [批量大小, 目标序列长度]
        x, y = x.to(device), y.to(device)                                       # 指定设备

        # targets: [[<sos>, *, *, <eos>, <pad>], [<sos>, *, *, <eos>, <pad>], ...]
        targets = y.tolist()

        results = model_predict_one_batch(model, device, x, en_word2index, max_seq_len)

        predictions.extend(results)
        references.extend([[target[1 : target.index(en_word2index[EOS_TOKEN])]] for target in targets])

    accuracy = corpus_bleu(references, predictions)

    print(f"准确率:{accuracy:.4f}")

if __name__ == "__main__":
    pad_token_index = SPECIAL_TOKENS.index(PAD_TOKEN)
    padding_idxs = [pad_token_index] * 2
    
    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

    # 加载词表
    file_path = os.path.join(DATA_DRIECTORY, "zh_cn_vocab.txt")
    with open(file_path, "r", encoding="utf-8") as f:
        zh_cn_vocab_list = [line.strip() for line in f.readlines()]

    print("加载中文词表完成")

    file_path = os.path.join(DATA_DRIECTORY, "en_vocab.txt")
    with open(file_path, "r", encoding="utf-8") as f:
        en_vocab_list = [line.strip() for line in f.readlines()]

    vocab_sizes = [len(zh_cn_vocab_list), len(en_vocab_list)]

    print("加载英文词表完成")

    # 构建词表索引
    en_word2index = {word: index for index, word in enumerate(en_vocab_list)}
    print("构建英文词表索引完成")

    file_path = os.path.join(DATA_DRIECTORY, "translation_test.jsonl")
    test_dataset = TranslationDataset(file_path)                                # 创建测试数据集
    print("创建测试集成功")

    test_data_loader = DataLoader(test_dataset, BATCH_SIZE, collate_fn=collate_fn)  # 创建测试数据集加载器
    print("创建测试数据集加载器成功")

    model = TranslationModel(vocab_sizes, EMBEDDING_DIM, HIDDEN_DIM, NUM_LAYERS, padding_idxs).to(device)
    print("创建模型成功")

    file_path = os.path.join(MODEL_DRIECTORY, "translation_model_by_seq2seq.pth")
    model.load_state_dict(torch.load(file_path))                                # 加载模型参数
    print("加载模型参数完成")

    model_evaluate(model, device, test_data_loader, en_word2index, MAX_SEQ_LEN)

  模型预测:

import os

import torch

from nltk.tokenize import TreebankWordDetokenizer

def model_predict(model, device, text, zh_cn_word2index, en_word2index, en_index2word, detokenizer, max_seq_len):
    model.to(device)                                                            # 指定设备
    tokens = list(text)                                                         # 切词
    zh_cn_indexes = [zh_cn_word2index.get(token, SPECIAL_TOKENS.index(UNK_TOKEN)) for token in tokens]  # 获取单词的索引
    x = torch.tensor([zh_cn_indexes], dtype=torch.long, device=device)          # 输入数据

    results = model_predict_one_batch(model, device, x, en_word2index, max_seq_len)

    tokens = [en_index2word[index] for index in results[0]]
    return detokenizer.detokenize(tokens)

def model_run_predict(model, device, word2index, en_word2index, en_index2word, max_seq_len):
    print("欢迎使用中英翻译模型,输入q或quit退出")

    detokenizer = TreebankWordDetokenizer()

    while True:
        text = input("请输入中文: \n")
        if text.strip() in ["q", "quit"]:
            break

        if text.strip() == "":
            continue
        
        print(f"输入的中文:")
        print(text)
        
        result = model_predict(model, device, text, word2index, en_word2index, en_index2word, detokenizer, max_seq_len)
        print(f"翻译结果:")
        print(result)

if __name__ == "__main__":
    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

    file_path = os.path.join(DATA_DRIECTORY, "zh_cn_vocab.txt")
    with open(file_path, "r", encoding="utf-8") as f:
        zh_cn_vocab_list = [line.strip() for line in f.readlines()]

    print("加载中文词表完成")

    file_path = os.path.join(DATA_DRIECTORY, "en_vocab.txt")
    with open(file_path, "r", encoding="utf-8") as f:
        en_vocab_list = [line.strip() for line in f.readlines()]

    vocab_sizes = [len(zh_cn_vocab_list), len(en_vocab_list)]

    print("加载英文词表完成")

    vocab_sizes = [len(zh_cn_vocab_list), len(en_vocab_list)]

    # 构建词表到索引的映射
    zh_cn_word2index = {word: index for index, word in enumerate(zh_cn_vocab_list)}
    print("构建中文词表到索引的映射")
    
    en_word2index = {word: index for index, word in enumerate(en_vocab_list)}
    print("构建英文词表到索引的映射")

    # 构建索引到词表的映射
    en_index2word = {index: word for index, word in enumerate(en_vocab_list)}
    print("构建英文词引到词表的映射")
    
    model = TranslationModel(vocab_sizes, EMBEDDING_DIM, HIDDEN_DIM, NUM_LAYERS, padding_idxs).to(device)
    print("创建模型完成")

    file_path = os.path.join(MODEL_DRIECTORY, "translation_model_by_seq2seq.pth")
    model.load_state_dict(torch.load(file_path))                                # 加载模型参数
    print("加载模型参数完成")

    model_run_predict(model, device, zh_cn_word2index, en_word2index, en_index2word, MAX_SEQ_LEN)
posted @ 2026-10-10 07:34  星光映梦  阅读(3)  评论(0)    收藏  举报