四、自然语言处理_05Seq2Seq模型与案例

1、Seq2Seq基础知识

1.1 定义

Seq2Seq(Sequence-to-Sequence)是一种处理序列到序列问题的深度学习模型,广泛应用于机器翻译、文本摘要、语音识别、聊天机器人等自然语言处理任务中

  • Seq(Sequence):指的是输入和输出都是序列数据,可以是文本、语音、时间序列信号等
  • 2:表示转换(transduction),即将一种序列转换为另一种序列(也就是将输入序列转换为输出序列)
  • 本质:学习输入序列和输出序列之间的映射关系

1.2 推理流程

  • 理解上文:模型首先需要理解输入序列的含义(聆听别人的问题,思考别人的问题)
  • 中间表达:模型将理解的内容编码成一个中间状态,这个状态捕捉了输入序列的主要信息(问题在大脑中理解完之后的结果)
  • 生成下文:模型根据中间状态生成输出序列(参考问题和自己的知识来作答)

2、Seq2Seq模型结构

Seq2Seq的结构如下图所示

通过上图可知,Seq2Seq模型由Encoder、c、Decoder这三部分组成

2.1 Encoder

Encoder指的是编码器,其用于处理输入序列,并将其编码为固定长度的上下文向量(context vector),这个向量包含了输入序列的全部信息

其处理逻辑如下:

Step1: 对于"欢迎来北京"这句话,会先进行分词,将其分隔为"欢迎"、"来"、"北京"三个词

Step2: Embedding将"欢迎"、"来"、"北京"三个词转换为三个对应的向量v0、v1、v2

Step3: 编码器中包含多个GRU(门循控制单元):

  • 第一个GRU,输入第一个向量v0和初始隐藏状态h0,输出最终隐藏状态h1

  • 第二个GRU,输入第二个向量v1和第一个GRU的最终隐藏状态h1,输出最终隐藏状态h2

  • 第三个GRU,输入第三个向量v2和第二个GRU的最终隐藏状态h2,输出最终隐藏状态h3

2.2 C

C指的是Context Vector(即:上下文向量),它其实就是上面Encoder中最后一个GRU输出的最终隐藏状态h3,包含了包含了输入序列的压缩信息,用于初始化解码器

2.3 Decoder

Decoder指的是解码器,其用于根据编码器提供的上下文向量生成输出序列

其处理逻辑如下:

Step1: 编码器和解码器内部都是GRU,需要有一个标记(比如GO),来对解码器作出指示:从GO对应的GRU这里开始生成输出序列(这个GO标记也会经过Embedding处理,转换为对应的向量)

Step2: 编码器中也包含多个GRU(门循控制单元):

  • 第一个GRU,通过"GO"标记对应的向量指示其开始做处理,输入上下文向量c和初始隐藏状态s0,得到输出向量welcome和最终隐藏状态s1

  • 第二个GRU,输入上下文向量c和上一步GRU的最终隐藏状态s1(同时,会在Teacher Forcing策略的作用下,接收上一步GRU应该得到的正确输出向量welcome),得到输出向量to和最终隐藏状态s2

  • 第三个GRU,输入上下文向量c和上一步GRU的最终隐藏状态s2(同时,会在Teacher Forcing策略的作用下,接收上一步GRU应该得到的正确输出向量to),得到输出向量BeiJing和最终隐藏状态s3

  • 第四个GRU,输入上下文向量c和上一步GRU的最终隐藏状态s3(同时,会在Teacher Forcing策略的作用下,接收上一步GRU应该得到的正确输出向量BeiJing),得到输出向量EOS(与"GO"类似,"EOS"也是一个重要的标记,"GO"标记开始,"EOS"标记结束)

3、文本翻译案例

3.1 需求概述

现有一个《data.txt》文件,里面存放了很多组翻译对(即:英文句子 - 中文句子 的组合)

要求针对此《data.txt》文件,使用Seq2Seq模型构建一个翻译系统,并验证翻译效果

3.2 需求分析

这是一个典型的翻译任务,要求系统在用户输入英文句子之后,输出与之对应的中文句子,可以用Seq2Seq模型来实现

具体来说,至少应该考虑以下几个要点:

  • 1、分词器:自定义一个分词器,用于根据输入的语料构建字典

    • 输入的句子(src --> source,英文句子)构建两个字典

      • src_token2idx字典的格式为:{英文词:id}

      • src_idx2token字典的格式为:{id:英文词}

    • 输出的句子(tgt --> target,中文句子)构建两个字典

      • tgt_token2idx字典的格式为:{中文词:id}

      • tgt_idx2token字典的格式为:{id:中文词}

  • 2、数据打包工具:自定义一个数据集和数据的批处理函数,用于将《data.txt》文件中的内容打包成模型处理所需的数据格式

  • 3、Seq2Seq模型:包含编码器和解码器方法

  • 4、模型训练和推理:自定义模型的训练和推理方法

3.3 代码实现

3.3.0 导包

import os
import joblib
import pandas as pd
import jieba
import opencc
import random

import time
from tqdm import tqdm

import torch
from torch import nn
from torch.utils.data import Dataset
from torch.utils.data import DataLoader

3.3.1 构建分词器,并实例化分词器对象

(1)构建

class Tokenizer(object):
    """
        自定义一个分词器,用于根据输入的语料构建字典:
            1、输入的句子(src --> source,英文句子)构建两个字典
                src_token2idx字典的格式为:{英文词:id}
                src_idx2token字典的格式为:{id:英文词}
            2、输出的句子(tgt --> target,中文句子)构建两个字典
                tgt_token2idx字典的格式为:{中文词:id}
                tgt_idx2token字典的格式为:{id:中文词}
    """
    def __init__(self, data_file):
        """
            初始化方法:
                1、获取语料文件的路径
                2、定义idx与token之间的字典,并将其置空
        """
        # 定义语料文件的路径
        self.data_file = data_file
        # 输入侧 src --> source
        self.src_token2idx = None
        self.src_idx2token = None
        # 输出侧 tgt --> target
        self.tgt_token2idx = None
        self.tgt_idx2token = None
        # 构建字典
        self._build_dict()

    def _build_dict(self):
        """
            构建字典
        """
        # 1、如果四个字典都有值,则不需要浪费资源重复构建,跳出这个构建字典的_build_dict方法即可
        if all([self.src_token2idx, self.src_idx2token, self.tgt_token2idx, self.tgt_idx2token]):
            print("字典已经构建过了")
            return
        # 2、如果缓存里面有都通过joblib保存的字典文件,则也不需要浪费资源重复构建,直接从字典文件中获取字典,再跳出这个构建字典的_build_dict方法即可
        elif os.path.exists(os.path.join(".cache", "dicts.lc")):
            print("从缓存中读取字典")
            self.src_token2idx, self.src_idx2token, self.tgt_token2idx, self.tgt_idx2token = joblib.load(filename=os.path.join(".cache", "dicts.lc"))
            return

        # 3、如果上面两个条件都不满足,则开始从零构建字典
        # 3.1 从文件中读取数据,并将英文句子和中文句子通过中间的制表符分开(置为DataFrame对象)
        data = pd.read_csv(filepath_or_buffer=self.data_file, sep="\t", header=None)
        # 3.2 将英文句子视为src,将中文句子视为tgt
        data.columns = ["src", "tgt"]
        # 3.3 获取数据的行数和列数
        rows, cols  = data.shape
        # 3.4 构建标记元素集
        # <UNK>:未知标记,用于表示在词汇表中未出现的词,当模型遇到一个它在训练数据中未曾见过的词时,会用 <UNK> 来代替
        # <PAD>:填充标记,用于将所有输入序列填充到相同的长度,以便于能够使用批处理和固定大小的神经网络输入
        # <SOS>:序列开始标记,用于指示序列生成的开始
        # <EOS>:序列结束标记,用于指示序列生成的结束
        src_tokens = {"<UNK>", "<PAD>", "<SOS>", "<EOS>"}
        tgt_tokens = {"<UNK>", "<PAD>", "<SOS>", "<EOS>"}
        # 3.5 遍历语料文件中的每一行内容(同时采用tqdm显示进度条)
        for row_idx in tqdm(range(rows)):
            # 3.5.1 将每一行内容都分为src(resource,英文句子)和tgt(target,中文句子)两类句子
            src, tgt = data.loc[row_idx, :]
            # 3.5.2 对英文句子进行切分处理,去重后存入src_tokens中
            src_tokens.update(set(self.split_english_sentence(src)))
            # 3.5.3 对中文句子进行切分处理,去重后存入tgt_tokens中
            tgt_tokens.update(set(self.split_chinese_sentence(tgt)))

        # 3.6 构建src的字典,包括src_token2idx和src_idx2token
        self.src_token2idx = {token: idx for idx, token in enumerate(src_tokens)}
        self.src_idx2token = {idx: token for token, idx in self.src_token2idx.items()}

        # 3.7 构建tgt的字典,包括tgt_token2idx和tgt_idx2token
        self.tgt_token2idx = {token: idx for idx, token in enumerate(tgt_tokens)}
        self.tgt_idx2token = {idx: token for token, idx in self.tgt_token2idx.items()}

        # 3.8 将上面构建好的四个字典都存放至缓存文件中,方便后面读取
        dicts = [self.src_token2idx, self.src_idx2token, self.tgt_token2idx, self.tgt_idx2token]
        if not os.path.exists(".cache"):
            os.makedirs(".cache")
        joblib.dump(value=dicts, filename=os.path.join(".cache", "dicts.lc"))

    def split_english_sentence(self, sentence):
        """
            英文句子切分
        """
        # 1、去除首尾空格
        sentence = sentence.strip()
        # 2、将句子进行进行 转小写-->分词-->去除空词和'-->存列表 的操作
        tokens = [token for token in jieba.lcut(sentence.lower()) if token not in ("", " ", "'")]
        # 3、返回处理后的列表
        return tokens

    def split_chinese_sentence(self, sentence):
        """
            中文句子切分
        """
        # 1、实例化opencc工具,并设置繁体转简体模式
        # t2s,即:Traditional Chinese to Simplified Chinese,表示将繁体句子转换为简体句子
        # s2t,即:Simplified Chinese to Traditional Chinese,表示将简体句子转换为繁体句子
        converter = opencc.OpenCC(config="t2s")
        # 2、进行句子转换
        sentence = converter.convert(text=sentence)
        # 3、将句子进行进行 分词-->去除空词-->存列表 的操作
        tokens = [token for token in jieba.lcut(sentence) if token not in ["", " "]]
        # 4、返回处理后的列表
        return tokens

    def encode_src(self, src_sentence, src_max_len):
        """
            对src进行编码:把英文句子分词后变成 id
                1、按本批次的最大长度来填充,没有见过的词置为"<UNK>",长度不够的用多个"<PAD>"填充
                2、src不用加"<SOS>和"<EOS>"
        """
        # 1、"<UNK>"转换
        src_idx = [self.src_token2idx.get(token, self.src_token2idx.get("<UNK>")) for token in src_sentence]
        # 2、"<PAD>"填充
        src_idx = (src_idx + [self.src_token2idx.get("<PAD>")] * src_max_len)[:src_max_len]
        # 3、返回处理后的id值
        return src_idx

    def encode_tgt(self, tgt_sentence, tgt_max_len):
        """
            对tgt进行编码:把分词后的tgt句子变成 id
                1、tgt需要在首尾分别加"<SOS>和"<EOS>"
                2、按本批次的最大长度来填充,没有见过的词置为"<UNK>",长度不够的用多个"<PAD>"填充
        """
        # 1、在首尾分别加"<SOS>和"<EOS>"
        tgt_sentence = ["<SOS>"] + tgt_sentence + ["<EOS>"]
        # 2、因为在首尾分别加了"<SOS>和"<EOS>",共两个词,所以最大长度要加2
        tgt_max_len += 2
        # 3、"<UNK>"转换
        tgt_idx = [self.tgt_token2idx.get(token, self.tgt_token2idx.get("<UNK>")) for token in tgt_sentence]
        # 4、"<PAD>"填充
        tgt_idx = (tgt_idx + [self.tgt_token2idx.get("<PAD>")] * tgt_max_len)[:tgt_max_len]
        # 5、返回处理后的id值
        return tgt_idx

    def decode_src(self, src_ids):
        """
            对src进行解码:把生成的id序列转换为英文token
        """
        outs = []
        # [batch_size, seq_len]
        for temp_src in src_ids:
            # 将不等于"<PAD>"的id获取出来,如果能找到id对应的token(英文词),则输出对应的token;如果找不到,则输出"<UNK>"
            outs.append([self.src_idx2token.get(src_id, "<UNK>") for src_id in temp_src if src_id != tokenizer.src_token2idx.get("<PAD>")])
        return outs

    def decode_tgt(self, tgt_ids):
        """
            对tgt进行解码:把生成的id序列转换为中文token
        """
        y_preds = []
        # [batch_size, seq_len]
        for temp_tgt in tgt_ids:
            # 将不等于"<SOS>"、"<EOS>"、"<PAD>"的id获取出来,并输出id对应的token(中文词)
            # 正如人们赚不到自己认知范围之外的钱一样,生成的序列也不会有找不到的id,所以不需要替换输出"<UNK>"
            y_preds.append([self.tgt_idx2token.get(tgt_id) for tgt_id in temp_tgt if tgt_id not in(tokenizer.tgt_token2idx.get("<SOS>"), tokenizer.tgt_token2idx.get("<EOS>"), tokenizer.tgt_token2idx.get("<PAD>"))])
        return y_preds

(2)实例化

# 实例化分词器对象,以供后面其他函数对其进行使用
tokenizer = Tokenizer(data_file="data.txt")

3.3.2 数据打包

(1)自定义数据集实现类

class Seq2SeqDataset(Dataset):
    """
        自定义数据集
    """
    def __init__(self, data_file, part="train"):
        """
            初始化方法
        """
        # 定义语料文件的路径
        self.data_file = data_file
        # 确认数据集的用途
        self.part = part
        # 将数据置空
        self.data = None
        # 加载数据
        self._load_data()

    def _load_data(self):
        """
            加载数据
        """
        # 1、如果数据有值,则不需要浪费资源重新加载,跳出这个加载数据的_load_data方法即可
        if self.data:
            print("数据集已经构建过了")
            return
        # 2、如果缓存里面有通过joblib保存的数据文件,则也不需要浪费资源重新加载,直接从数据文件中获取数据,再跳出这个加载数据的_load_data方法即可
        elif os.path.exists(os.path.join(".cache", "data.lc")):
            print("从缓存中读取数据")
            # 原始数据
            data = joblib.load(filename=os.path.join(".cache", "data.lc"))
            # 数据集切分:80%训练集,20%测试集
            nums = int(len(data) * 0.8)
            self.data = data[:nums] if self.part == "train" else data[nums:]
            return
        # 3、如果上面两个条件都不满足,则开始从零加载数据
        # 3.1 从文件中读取数据,并将英文句子和中文句子通过中间的制表符分开(置为DataFrame对象)
        data = pd.read_csv(filepath_or_buffer=self.data_file, sep="\t", header=None)
        # 3.2 将DataFrame中的数据随机打乱顺序,并转换为NumPy数组
        # sample方法用于随机采样;frac即fraction,用于指定从数据集中随机抽取的行数的比例,frac=1代表取100%的比例
        data = data.sample(frac=1).to_numpy()
        # 3.3 将上面加载好的数据存放至缓存文件中,方便后面读取
        joblib.dump(value=data, filename=os.path.join(".cache", "data.lc"))
        # 3.4 数据集切分:80%训练集,20%测试集
        nums = int(len(data) * 0.80)
        self.data = data[:nums] if self.part == "train" else data[nums:]

    def __len__(self):
        """
            DataLoader在每次迭代时也都会默认调用__len__方法,从而获取样本数量
        """
        return len(self.data)

    def __getitem__(self, idx):
        """
            DataLoader在每次迭代时都会默认调用Dataset中的__getitem__方法,从而获取单个数据样本并进行批处理
        """
        src, tgt = self.data[idx]
        src = tokenizer.split_english_sentence(src)
        tgt = tokenizer.split_chinese_sentence(tgt)
        return src, len(src), tgt, len(tgt)

(2)自定义回调函数

def collate_fn(batch):
    """
        回调函数,自定义数据的批处理逻辑
    """
    # 1、根据英文token的长度,来降序排列batch中的数据
    # 数据的格式是[src, len(src), tgt, len(tgt)],所以ele[1]代表len(src),即:英文token的长度
    batch = sorted(batch, key=lambda ele: ele[1], reverse=True)
    # 2、将所有batch中的内容打包,并以元组的形式赋值给src_sentences, src_lens, tgt_sentences, tgt_lens
    # zip中可以用(batch[0], batch[1], batch[2]),表示前三个batch,如果用*batch,则表示所有的batch
    # 用zip可以把每一batch中的"列元素"对应性的组合到一起,比如src_sentences中存放的是每一batch的src_sentence连起来的元组,src_lens中存放的是每一batch的src_len连起来的元组,依此类推
    src_sentences, src_lens, tgt_sentences, tgt_lens = zip(*batch)

    # 3、调用tokenizer中的编码方法encode_src,将 src 转 id
    # 因为根据src_lens进行了降序排列,所以src_max_len = src_lens[0]
    src_max_len = src_lens[0]
    src_idxes = []
    for src_sentence in src_sentences:
        src_idxes.append(tokenizer.encode_src(src_sentence, src_max_len))

    # 4、调用tokenizer中的编码方法encode_tgt,将 tgt 转 id 
    # 通过max方法计算tgt_max_len
    tgt_max_len = max(tgt_lens)
    tgt_idxes = []
    for tgt_sentence in tgt_sentences:
        tgt_idxes.append(tokenizer.encode_tgt(tgt_sentence, tgt_max_len))

    # 5、所有数据转张量 torch.long
    # 用t()进行张量的转置,[batch_size, src_max_len] --> [src_max_len, batch_size]
    src_idxes = torch.tensor(data=src_idxes, dtype=torch.long).t()
    # [batch_size, ]
    src_lens = torch.tensor(data=src_lens, dtype=torch.long)
    # 用t()进行张量的转置,[batch_size, tgt_max_len + 2] --> [tgt_max_len + 2, batch_size]
    tgt_idxes = torch.tensor(data=tgt_idxes, dtype=torch.long).t()
    # [batch_size, ]
    tgt_lens = torch.tensor(data=tgt_lens, dtype=torch.long)

    return src_idxes, src_lens, tgt_idxes, tgt_lens

(3)加载数据集

# 传入回调函数,训练集
train_dataset = Seq2SeqDataset(data_file="data.txt", part="train")
train_dataloader = DataLoader(dataset=train_dataset, shuffle=True, batch_size=32, collate_fn=collate_fn)

# 传入回调函数,测试集
test_dataset = Seq2SeqDataset(data_file="data.txt", part="test")
test_dataloader = DataLoader(dataset=test_dataset, shuffle=False, batch_size=32, collate_fn=collate_fn)

3.3.3 模型搭建

(1)编码器设计

class Encoder(nn.Module):
    """
        自定义一个编码器,处理 src
    """
    def __init__(self, num_embeddings=len(tokenizer.src_token2idx), embedding_dim=256):
        super().__init__()
        self.embed = nn.Embedding(num_embeddings=num_embeddings, embedding_dim=embedding_dim, padding_idx=tokenizer.src_token2idx.get("<PAD>"))
        self.gru = nn.GRU(input_size=embedding_dim, hidden_size=embedding_dim)

    def forward(self, src, src_lens):
        """
            前向传播,消除 PAD 影响
        """
        # [src_max_len, batch_size] --> [src_max_len, batch_size, embed_dim]
        src = self.embed(src)

        # 压紧被填充的序列
        # pack_padded_sequence将填充后的序列打包成PackedSequence对象,以提高循环神经网络处理变长序列的效率
        # batch_first表示输入张量的第一个维度是否为批次大小(如果为 True,则输入形状为 [batch_size, seq_len, *];如果为 False,则为 [seq_len, batch_size, *])
        src = nn.utils.rnn.pack_padded_sequence(input=src, lengths=src_lens, batch_first=False)
        out, hn = self.gru(src)

        # 维度转换:[num_layers * num_directions, batch_size, hidden_size]-->[batch_size, hidden_size]
        return hn[0, :, :]

(2)解码器设计

class Decoder(nn.Module):
    """
        自定义一个解码器:
            1、训练时考虑 teacher forcing
            2、推理时考虑 自回归
    """
    def __init__(self, num_embeddings=len(tokenizer.tgt_token2idx), embedding_dim=256):
        super().__init__()

        self.embed = nn.Embedding(num_embeddings=num_embeddings, embedding_dim=embedding_dim, padding_idx=tokenizer.tgt_token2idx.get("<PAD>"))

        # 手动挡,分步特征抽取,实现自回归逻辑
        self.gru_cell = nn.GRUCell(input_size=embedding_dim, hidden_size=embedding_dim)

        # 输出 embed_dim --> dict_len
        self.out = nn.Linear(in_features=embedding_dim, out_features=len(tokenizer.tgt_token2idx))

    def forward(self, context, tgt):
        """
            训练时的正向推理:
                context: 上下文向量,中间表达
                tgt:标签
                tgt_lens:生成的句子的有效长度(不包含"<SOS>"和"<EOS>")     
        """
        # 1、定义生成侧的输入输出
        # 以welcome to BeiJing为例,粗略来看,完整的句子是<SOS> welcome to BeiJing <EOS>,输入是<SOS> welcome to BeiJing,输出是welcome to BeiJing <EOS>
        # 所以,输入的是除了最后一个元素之外的内容,即:tgt[:-1, :];输出的是除了第一个元素之外的内容,即:tgt[1:, :]
        tgt_input = tgt[:-1, :]
        tgt_output = tgt[1:, :]
        # 2、从tgt_input中获取SEQ_LEN和BATCH_SIZE
        SEQ_LEN, BATCH_SIZE = tgt_input.shape

        # 3、准备初始状态
        hn = context
        # 4、从tgt_input中获取step_input
        # tgt_input[0, :]-->从tgt_input中提取第一个时间步(即序列的开始)的所有批次数据,tgt_input 的形状是 [SEQ_LEN - 1, BATCH_SIZE],所以 tgt_input[0, :] 的形状是 [BATCH_SIZE]
        # .view(1, -1)-->view是PyTorch中用于改变张量形状的方法,不改变数据本身,这里的view(1, -1)表示将张量的形状改变为 [1, BATCH_SIZE]
        # self.embed(...)-->调用Embedding层,将输入的索引转换为嵌入向量,[1, BATCH_SIZE]转换为[1, BATCH_SIZE, embedding_dim]
        # [0, :, :]-->将张量的形状从[1, BATCH_SIZE, embedding_dim]转换为[BATCH_SIZE, embedding_dim]
        step_input = self.embed(tgt_input[0, :].view(1, -1))[0, :, :]

        # 5、有多少步,就循环多少次
        outs = []
        for step in range(SEQ_LEN):
            # 5.1 正向传播
            hn = self.gru_cell(step_input, hn)
            # 5.2 生成预测结果
            y_pred = self.out(hn)
            # 5.3 保留所有生成的预测结果(做交叉熵损失用)
            outs.append(y_pred)
            # 5.4 判断下一步的step_input是什么
            if step < SEQ_LEN - 1:
                # 5.4.1 训练时采用 50% 的概率去使用 teacher forcing 优化策略
                teacher_forcing = (random.random() >= 0.5)
                if teacher_forcing:
                    # 5.4.2 如果采用teacher_forcing,则需要输入标准答案
                    step_input = self.embed(tgt_input[step + 1, :].view(1, -1))[0, :, :]
                else:
                    # 5.4.3 如果不采用teacher_forcing, 则输入上一次生成的结果
                    # argmax(dim=-1, keepdim=True)表示将最大概率的预测内容视为输出结果,并保持维度不变(贪心解码思想:只保证在每一步中都选择概率最高的词作为输出,不保证找到全局最优解,但计算效率较高)
                    y_pred = y_pred.argmax(dim=-1, keepdim=True).view(1, -1)
                    step_input = self.embed(y_pred)[0, :, :]
        # 将所有时间步的预测输出合并成一个批次,以便进行后续的处理
        return torch.stack(tensors=outs, dim=0)

    def infer(self, context, max_new_tokens=200):
        """
            推理专用:
                不使用teacher forcing,而是使用自回归
                context: 上下文向量,中间表达
                max_new_tokens:最大生成长度
        """
        # 1、从context中获取BATCH_SIZE
        BATCH_SIZE, _ = context.shape

        # 2、准备初始状态
        hn = context
        # 3、将"<SOS>"标记的id转换为嵌入向量,并准备作为解码器在自回归推理过程中的初始输入
        step_input = self.embed(torch.tensor(data=[tokenizer.tgt_token2idx.get("<SOS>")] * BATCH_SIZE, 
                                             dtype=torch.long, 
                                             device=device
                                             ).view(1,  -1))[0, :, :]

        # 4、有多少步,就循环多少次
        outs = []
        for step in range(max_new_tokens):
            # 4.1 正向传播
            hn = self.gru_cell(step_input, hn)
            # 4.2 生成预测结果
            y_pred = self.out(hn)
            # 4.3 保留所有生成的预测结果(做交叉熵损失用)
            outs.append(y_pred)
            # 4.4 判断下一步的step_input是什么
            if step < max_new_tokens - 1:
                # 推理过程没有标准答案,不能采用teacher_forcing, 所以直接输入上一次生成的结果
                # argmax(dim=-1, keepdim=True)表示将最大概率的预测内容视为输出结果,并保持维度不变(贪心解码思想:只保证在每一步中都选择概率最高的词作为输出,不保证找到全局最优解,但计算效率较高)
                y_pred = y_pred.argmax(dim=-1, keepdim=True).view(1, -1)
                step_input = self.embed(y_pred)[0, :, :]
        # 将所有时间步的预测输出合并成一个批次,以便进行后续的处理
        return torch.stack(tensors=outs, dim=0)

(3)定义Seq2Seq模型的实现类

class Seq2Seq(nn.Module):
    """
        自定义一个Seq2Seq模型的实现类,并调用上面定义的编码器和解码器
    """
    def __init__(self):
        super().__init__()
        self.encoder = Encoder()
        self.decoder = Decoder()

    def forward(self, src, src_lens, tgt):
        """
            训练时使用的前向传播
        """
        context = self.encoder(src, src_lens)
        outs = self.decoder(context, tgt)
        return outs

    def infer(self, src, src_lens, max_new_tokens=128):
        """
            推理过程         
        """
        # 把 src 传入 encoder,获得中间表达 context
        context = self.encoder(src, src_lens)
        # 把 context 输入 decoder, 得到预测结果
        outs = self.decoder.infer(context, max_new_tokens=max_new_tokens)
        return outs

3.3.4 模型的训练与推理

(0)定义训练参数

# 设置训练轮次
epochs = 15

# 实例化模型及数据搬家
device = "cuda" if torch.cuda.is_available() else "cpu"
model = Seq2Seq()
model.to(device)

# 加载上一次的模型
last_model_path = os.path.join("models", "last.pt")
if os.path.exists(last_model_path):
    print("加载 last.pt 模型")
    model.load_state_dict(state_dict=torch.load(f=last_model_path, weights_only=True, map_location=device))

# 定义优化器
optimizer = torch.optim.Adam(params=model.parameters(), lr=1e-3)
# 定义损失函数
loss_fn = nn.CrossEntropyLoss()

(1)定义训练函数

def train():
    """
        自定义模型的训练方法
    """
    # 1、判断是否存在记录模型pt权重文件和csv损失值文件的models目录
    # 存在则执行后面代码,不存在则先创建文件夹再执行后面代码
    if not os.path.exists("models"):
        os.mkdir("models")
    # 2、判断是否存在记录csv损失值的losses.csv文件
    loss_file_path = os.path.join("models", "losses.csv")
    # 2.1 如果存在,则读取对应的损失值
    if os.path.exists(loss_file_path):
        data = pd.read_csv(filepath_or_buffer=loss_file_path)
        best_loss = data.loc[0, "best_loss"]
        last_loss = data.loc[0, "last_loss"]
    # 2.2 如果不存在,则初始化损失值为float("inf") ,即:无穷大(infinity)的浮点数
    else:
        best_loss = float("inf")
        last_loss = float("inf")
    # 2.3 打印损失值
    print(f"best_loss:{best_loss}")
    print(f"last_loss:{last_loss}")
    # 2.4 初始化早停策略计数器
    # 早停(Early Stopping)是一种防止模型过拟合的技术,一般的做法是:当监控到损失值没有明显下降时,就将计数器+1,而当计数器达到预设的耐心值(patience)时,训练过程就会被提前终止
    early_stopping_count  = 0
    # 2.5 开始轮询处理
    for epoch in range(epochs):
        # 2.5.1 设置模型为train(训练)模式
        model.train()
        # 2.5.2 初始化用于存放每轮损失函数值的列表
        running_epoch_losses = []
        # 2.5.3 获取本轮开始时间
        start_time = time.time()
        # 2.5.4 从训练集中获取数据进行处理(同时采用tqdm显示进度条)
        for src, src_lens, tgt, tgt_lens in tqdm(train_dataloader):
            # (0)数据搬家
            src = src.to(device=device)
            src_lens = src_lens
            tgt = tgt.to(device=device)
            tgt_lens = tgt_lens
            # (1)正向传播
            outs = model(src, src_lens, tgt)
            # (2)计算损失(PAD做了对齐,但不对齐也是可以的)
            # 把第0个元素(<SOS>)干掉(粗糙的做法,只干掉了<SOS>,没有干掉<pad>)
            # contiguous()方法用于确保张量在内存中是连续存储的,这样可以提高性能
            tgt = tgt[1:, :].contiguous().view(-1)
            # 展平:[sequence_length, batch_size, num_embeddings]--> [batch_size * sequence_length, num_embeddings]
            outs = outs.contiguous().view(-1, outs.size(-1))
            loss = loss_fn(outs, tgt)
            # (3)反向传播
            loss.backward()
            # (4)优化一步
            optimizer.step()
            # (5)清空梯度
            optimizer.zero_grad()
            # (6)累积损失
            running_epoch_losses.append(loss.item())

        # 2.5.5 获取本轮结束时间
        stop_time = time.time()
        # 2.5.6 计算损失的平均值
        running_epoch_loss = sum(running_epoch_losses) / len(running_epoch_losses)
        print(f"当前轮次: {epoch + 1} / {epochs} , 累积损失: {running_epoch_loss}, 耗时: {stop_time-start_time} S")
        # 2.5.7 调用推理函数观察结果
        observe_the_result()
        # 2.5.8 保存模型(比较损失值,如果比之前最小的损失值还要小,则代表这个模型更好,所以应该进一步保存下来)
        # (1)保存best.pt
        if running_epoch_loss < last_loss:
            last_loss = running_epoch_loss
            best_loss = running_epoch_loss
            best_model_path = os.path.join("models", "best.pt")
            torch.save(obj=model.state_dict(), f=best_model_path)
        # (2)保存 last.pt
        last_model_path = os.path.join("models", "last.pt")
        torch.save(obj=model.state_dict(), f=last_model_path)
        # (3)保存损失值loss
        loss_file_path = os.path.join("models", "losses.csv")
        last_loss = running_epoch_loss
        data = pd.DataFrame(data={"best_loss":[best_loss], "last_loss": [last_loss]})
        data.to_csv(path_or_buf=loss_file_path, index=None)

(2)定义推理函数

def observe_the_result(K=3, dataloader=train_dataloader):
    """
        在每轮训练结束后,抽取K个句子来观察效果
    """
    # 设置模型为eval(评估)模式
    model.eval()
    with torch.no_grad():
        for src, src_lens, tgt, tgt_lens in dataloader:
            # 1、数据搬家
            src = src.to(device=device)
            src_lens = src_lens
            # 2、用模型的推理函数,获得预测结果
            y_preds = model.infer(src=src, src_lens=src_lens, max_new_tokens=128)
            y_preds = y_preds.argmax(dim=-1).permute(dims=(1, 0)).cpu().numpy()

            # 3、获取批次大小
            _, batch_size = src.shape
            # 4、从批次中随机抽取K个句子,得到对应的src、tgt以及y_preds句子
            samples_ids = random.sample(population=range(batch_size), k=K)
            src = src[:, samples_ids]
            tgt = tgt[:, samples_ids]
            y_preds = y_preds[samples_ids, :]
            # 5、将src、tgt以及y_preds句子进行分词
            src_tokens = tokenizer.decode_src(src_ids=src.permute(dims=(1, 0)).cpu().numpy())
            tgt_tokens = tokenizer.decode_tgt(tgt_ids=tgt.permute(dims=(1, 0)).cpu().numpy())
            y_preds = tokenizer.decode_tgt(tgt_ids=y_preds)
            # 6、打印结果:原英文句子对应的词是什么,原中文句子对应的词是什么,预测出来的句子对应的中文词是什么
            for idx in range(K):
                print(f"# 第 {idx + 1} 句输入:{src_tokens[idx]}")
                print(f"# 第 {idx + 1} 句标签:{tgt_tokens[idx]}")
                print(f"# 第 {idx + 1} 句预测:{y_preds[idx]}")
                print("-" * 88)
            # 推理过程每轮显示k句看看效果就好,太多了会打印很多内容,不方便自己观察,所以打印前k句之后直接break退出,继续看下一轮
            break

(3)进行训练(训练方法中包含推理观察的调用)

# 执行训练
train()

效果:(刚开始蠢蠢的,后来变聪明了一些)

前几轮:

后几轮:

相关推荐
知来者逆4 分钟前
Layer-Condensed KV——利用跨层注意(CLA)减少 KV 缓存中的内存保持 Transformer 1B 和 3B 参数模型的准确性
人工智能·深度学习·机器学习·transformer
tangjunjun-owen10 分钟前
异常安全重启运行机制:健壮的Ai模型训练自动化
人工智能·python·安全·异常重运行或重启
爱研究的小牛32 分钟前
Rerender A Video 技术浅析(二):视频增强
人工智能·深度学习·aigc
Bdawn41 分钟前
【通义实验室】开源【文本生成图片】大模型
人工智能·python·llm
黑马王子131 小时前
谷歌史上最强大模型-Gemini2.0震撼发布!以后世界都属于智能体?
人工智能·google
电报号dapp1191 小时前
当前热门 DApp 模式解析:六大方向的趋势与创新
人工智能·去中心化·区块链·智能合约
宸码1 小时前
【机器学习】手写数字识别的最优解:CNN+Softmax、Sigmoid与SVM的对比实战
人工智能·python·神经网络·算法·机器学习·支持向量机·cnn
不会&编程1 小时前
Hyperbolic Representation Learning: Revisiting and Advancing 论文阅读
深度学习·图神经网络·dgl·pyg
睡觉狂魔er1 小时前
自动驾驶控制与规划——Project 1: 车辆纵向控制
人工智能·机器学习·自动驾驶
goomind1 小时前
YOLOv8实战bdd100k自动驾驶目标识别
人工智能·深度学习·yolo·计算机视觉·目标跟踪·自动驾驶·bdd100k