机器翻译模型笔记

发布于:2025-06-05 ⋅ 阅读:(78) ⋅ 点赞:(0)

机器翻译学习笔记(简体中文)

1. 任务概述

  • 目标:将英文句子翻译成简体中文。

  • 示例

    • 输入:Tom is a student.

    • 输出:汤姆是一个学生。

  • 框架:Seq2Seq(序列到序列)模型。

2. 数据预处理

2.1 下载数据

  • 数据集:TED2020(英文-简体中文对齐的平行语料)。

  • 代码

    # 下载TED2020数据集的压缩文件
    # - wget命令用于从指定URL下载文件
    # - -O选项指定下载文件的保存路径和名称
    # - 目的:获取训练所需的双语平行语料
    !wget https://github.com/yuhsinchan/ML2022-HW5Dataset/releases/download/v1.0.2/ted2020.tgz -O ./DATA/rawdata/ted2020/ted2020.tgz
    
    # 解压下载的压缩文件
    # - tar命令用于解压.tgz文件
    # - -xvf选项表示解压、显示详细信息、并指定文件
    # - -C选项指定解压的目标目录
    # - 目的:将压缩文件解压到指定的数据目录中
    !tar -xvf ./DATA/rawdata/ted2020/ted2020.tgz -C ./DATA/rawdata/ted2020/
  • 通俗总结:这段代码就像从网上下载一个装满英文和中文翻译句子的压缩包,然后把它解压到一个文件夹里。这些句子是模型训练的“教材”,里面有成千上万的英文句子和对应的中文翻译,方便模型学习怎么把英文变成中文。

2.2 清洗和规范化

  • 功能:移除噪声、统一格式。

  • 代码

    def clean_s(s, lang):
        """
        清洗和规范化文本数据,根据语言类型执行不同的预处理操作。
        
        参数:
        s (str): 输入的原始文本字符串。
        lang (str): 语言类型,'en' 表示英文,'zh' 表示中文。
        
        返回:
        str: 经过清洗和规范化后的文本字符串。
        """
        if lang == 'en':  # 如果语言是英文,进入英文文本处理逻辑
            # 移除括号及其内部内容
            # - 使用正则表达式匹配括号及其内容:\( 表示左括号,\) 表示右括号
            # - [^()]* 表示括号内不包含括号的任意字符,* 表示匹配零次或多次
            # - re.sub 将匹配的括号及其内容替换为空字符串
            # - 目的:移除补充说明或注释性内容,减少翻译时的噪声
            s = re.sub(r"\([^()]*\)", "", s)
            
            # 移除连字符 '-'
            # - 使用 replace 方法将所有的连字符替换为空字符串
            # - 目的:简化英文文本,减少不必要的符号,提升文本一致性
            s = s.replace('-', '')
            
            # 在标点符号前后添加空格
            # - ([.,;!?()\"]) 是一个正则表达式,匹配常见的英文标点符号
            # - r' \1 ' 表示在匹配的标点前后各添加一个空格,\1 代表匹配的标点本身
            # - re.sub 执行替换操作
            # - 目的:确保标点与单词分离,便于分词工具识别,提升模型处理能力
            s = re.sub('([.,;!?()\"])', r' \1 ', s)
        
        elif lang == 'zh':  # 如果语言是中文,进入中文文本处理逻辑
            # 将全角字符转换为半角字符
            # - strQ2B 是一个外部函数,专门用于将全角字符(如全角标点)转为半角字符
            # - 目的:统一字符编码,减少模型处理不同编码字符时的复杂度
            s = strQ2B(s)
            
            # 移除括号及其内部内容
            # - 与英文处理相同,使用正则表达式匹配并移除括号及其内容
            # - \( 和 \) 分别表示左右括号,[^()]* 表示括号内的任意非括号字符
            # - 目的:去除补充说明或非主要内容,保持翻译数据的干净
            s = re.sub(r"\([^()]*\)", "", s)
            
            # 移除空格和特定字符,并统一引号样式
            # - replace(' ', '') 移除所有空格,因为中文文本通常不依赖空格分词
            # - replace('—', '') 移除中文破折号,清理不必要的符号
            # - replace('“', '"') 和 replace('”', '"') 将中文引号替换为英文引号
            # - 目的:规范化中文文本,去除冗余符号,保持格式一致性
            s = s.replace(' ', '').replace('—', '').replace('“', '"').replace('”', '"')
            
            # 在中文标点符号前后添加空格
            # - ([。,;!?()\"~「」]) 匹配常见的中文标点符号
            # - r' \1 ' 在匹配的标点前后添加空格,\1 表示匹配的标点本身
            # - re.sub 执行替换操作
            # - 目的:将标点与文字分离,便于分词和模型处理标点
            s = re.sub('([。,;!?()\"~「」])', r' \1 ', s)
        
        # 规范化文本中的空格
        # - s.strip() 移除字符串首尾的多余空格
        # - split() 将字符串按空格分割成列表,自动移除连续空格
        # - ' '.join() 将列表元素用单个空格连接成字符串
        # - 目的:确保单词或字符之间只有一个空格,统一文本格式
        return ' '.join(s.strip().split())
  • 通俗总结:这段代码就像个“文本清洁工”,专门整理英文和中文句子。英文句子会把括号里的备注删掉、去掉连字符、给标点两边加空格;中文句子会把全角标点改成半角、删掉空格和破折号、统一引号样式,也给标点加空格。最后把多余的空格都清理掉,让句子看起来整洁统一,方便模型理解。

2.3 移除不良数据

  • 功能:根据长度和比例移除不合适的句子对。

  • 代码

    def clean_corpus(prefix, l1, l2, ratio=9, max_len=1000, min_len=1):
        # 打开源语言和目标语言的文件进行读取
        # - f'{prefix}.{l1}' 是源语言文件路径,l1 是源语言代码(如 'en')
        # - f'{prefix}.{l2}' 是目标语言文件路径,l2 是目标语言代码(如 'zh')
        with open(f'{prefix}.{l1}', 'r') as l1_in_f, open(f'{prefix}.{l2}', 'r') as l2_in_f:
            # 打开清洗后的源语言和目标语言文件进行写入
            # - f'{prefix}.clean.{l1}' 是清洗后的源语言文件路径
            # - f'{prefix}.clean.{l2}' 是清洗后的目标语言文件路径
            with open(f'{prefix}.clean.{l1}', 'w') as l1_out_f, open(f'{prefix}.clean.{l2}', 'w') as l2_out_f:
                # 逐行读取源语言文件
                for s1 in l1_in_f:
                    s1 = s1.strip()  # 去除源语言句子首尾的空白字符
                    s2 = l2_in_f.readline().strip()  # 读取目标语言的对应行并去除首尾空白
                    s1 = clean_s(s1, l1)  # 调用clean_s函数清洗源语言文本
                    s2 = clean_s(s2, l2)  # 调用clean_s函数清洗目标语言文本
                    s1_len = len_s(s1, l1)  # 计算源语言文本的长度(len_s是一个外部函数)
                    s2_len = len_s(s2, l2)  # 计算目标语言文本的长度
                    # 跳过过短的句子对
                    # - 如果任一语言的长度小于min_len(默认1),则跳过
                    if s1_len < min_len or s2_len < min_len:
                        continue
                    # 跳过过长的句子对
                    # - 如果任一语言的长度大于max_len(默认1000),则跳过
                    if s1_len > max_len or s2_len > max_len:
                        continue
                    # 跳过长度比例不合理的句子对
                    # - 如果源语言和目标语言的长度比例超过ratio(默认9),则跳过
                    # - 防止翻译对长度差异过大,影响模型训练
                    if s1_len / s2_len > ratio or s2_len / s1_len > ratio:
                        continue
                    # 将清洗后的句子写入对应文件
                    # - print默认会在末尾添加换行符,file参数指定输出文件
                    print(s1, file=l1_out_f)
                    print(s2, file=l2_out_f)
  • 通俗总结:这个代码就像个“句子筛选员”,把英文和中文的句子对一对一检查。先用 clean_s 清理句子,然后看看句子长度:太短(少于1个词)没啥用,太长(超过1000个词)模型吃不消,扔掉;如果英文和中文句子长度差太多(比如一个9倍长),可能翻译不靠谱,也扔掉。合格的句子对就保存到新文件里,留给模型用。

2.4 分词(Subword Units)

  • 工具:SentencePiece。

  • 代码

    import sentencepiece as spm
    # 训练SentencePiece模型
    # - input: 指定训练数据文件,多个文件用逗号分隔
    # - model_prefix: 模型文件的前缀,用于保存训练好的模型和词汇表
    # - vocab_size: 设置词汇表大小(8000),控制subword的数量
    # - model_type: 模型类型,'unigram' 是一种基于unigram语言模型的subword分割方法
    # - 目的:生成subword模型,将文本分割成子词单元,减少词汇量并处理未登录词
    spm.SentencePieceTrainer.train(
        input=','.join([f'{prefix}/train.clean.{src_lang}', f'{prefix}/valid.clean.{src_lang}']),
        model_prefix=f'{prefix}/spm8000',
        model_type='unigram'
    )
  • 通俗总结:这段代码用 SentencePiece 工具给句子“切词”。它不按完整单词切,而是把句子拆成小片段(像“playing”可能拆成“play”和“ing”),生成一个8000个“片段”的词典。这样模型不用记太多单词,也能处理没见过的词,训练起来更省力。

2.5 二值化(Binarize)

  • 工具:fairseq。

  • 代码

    # 使用fairseq的preprocess命令对数据进行二值化
    # - --source-lang: 指定源语言(如 'en')
    # - --target-lang: 指定目标语言(如 'zh')
    # - --trainpref, --validpref, --testpref: 指定训练、验证、测试数据集的前缀路径
    # - --destdir: 指定二值化数据的保存目录
    # - --joined-dictionary: 使用联合词典,即源语言和目标语言共享同一个词典
    # - --workers: 指定并行处理的worker数量,提升处理速度
    # - 目的:将文本数据转换为fairseq可直接使用的二进制格式,加速数据加载和训练
    !python -m fairseq_cli.preprocess \
        --source-lang {src_lang} --target-lang {tgt_lang} \
        --trainpref {prefix}/train --validpref {prefix}/valid --testpref {prefix}/test \
        --destdir {binpath} --joined-dictionary --workers 2
  • 通俗总结:这个代码把清理好的句子从文本变成“机器专用格式”(二进制文件),就像把书本内容压缩成电脑能快速读的代码。fairseq 工具会把训练、验证、测试数据都处理好,英文和中文用同一个词典,加快模型读取数据的速度。

3. 模型定义

3.1 RNN Seq2Seq 模型

3.1.1 编码器(RNNEncoder)
  • 功能:将输入句子转为嵌入向量,使用双向 GRU 处理。

  • 代码

    class RNNEncoder(FairseqEncoder):
        def __init__(self, args, dictionary, embed_tokens):
            # 初始化编码器,继承自FairseqEncoder
            super().__init__(dictionary)
            self.embed_tokens = embed_tokens  # 词嵌入层,将token ID映射为嵌入向量
            self.embed_dim = args.encoder_embed_dim  # 嵌入向量的维度,从参数中获取
            self.hidden_dim = args.encoder_ffn_embed_dim  # GRU隐藏层的维度,从参数中获取
            self.num_layers = args.encoder_layers  # GRU的层数,从参数中获取
            self.dropout_in_module = nn.Dropout(args.dropout)  # 在输入嵌入后应用的dropout层
            self.rnn = nn.GRU(
                self.embed_dim,  # 输入维度,即词嵌入的维度
                self.hidden_dim,  # 隐藏层维度,控制GRU的容量
                self.num_layers,  # GRU层数,决定深度
                dropout=args.dropout,  # dropout概率,防止过拟合
                batch_first=False,  # 输入形状为(seq_len, batch, embed_dim)
                bidirectional=True  # 双向GRU,捕捉前后上下文信息
            )
            self.dropout_out_module = nn.Dropout(args.dropout)  # 在GRU输出后应用的dropout层
            self.padding_idx = dictionary.pad()  # 从词典中获取padding token的ID
    
        def forward(self, src_tokens, **unused):
            # 前向传播函数,处理源语言输入
            bsz, seqlen = src_tokens.size()  # 获取batch size和序列长度
            # 将token ID转换为嵌入向量
            x = self.embed_tokens(src_tokens)  # 输出形状:(batch, seq_len, embed_dim)
            x = self.dropout_in_module(x)  # 在嵌入向量上应用dropout,减少过拟合
            x = x.transpose(0, 1)  # 调整为(seq_len, batch, embed_dim),适配GRU输入
            # 初始化隐藏状态
            # - new_zeros生成全零张量,形状为(2 * num_layers, batch, hidden_dim)
            # - 2 * num_layers 因为是双向GRU,每层有正向和反向
            h0 = x.new_zeros(2 * self.num_layers, bsz, self.hidden_dim)
            # 通过双向GRU处理输入
            # - 输出x: (seq_len, batch, hidden_dim*2),包含每个时间步的隐藏状态
            # - final_hiddens: (num_layers*2, batch, hidden_dim),每层的最终隐藏状态
            x, final_hiddens = self.rnn(x, h0)
            outputs = self.dropout_out_module(x)  # 在GRU输出上应用dropout
            # 处理双向GRU的隐藏状态
            # - combine_bidir是自定义方法,将正向和反向隐藏状态合并
            final_hiddens = self.combine_bidir(final_hiddens, bsz)
            # 创建padding mask,标记padding位置
            # - eq比较token是否等于padding_idx,t()转置为(seq_len, batch)
            encoder_padding_mask = src_tokens.eq(self.padding_idx).t()
            # 返回GRU输出、最终隐藏状态和padding mask
            return outputs, final_hiddens, encoder_padding_mask
  • 通俗总结:这段代码建了一个“句子理解器”(编码器)。它把英文句子里的每个词变成数字向量,再用双向 GRU(一种记忆力强的神经网络)从头到尾、从尾到头读一遍句子,记住每个词的上下文信息。GRU 像个聪明的笔记员,能记住重要的东西,忘了不重要的,最后输出句子的“精华信息”和一个标记,告诉模型哪些是填充的无效部分。

3.1.2 解码器(RNNDecoder)
  • 功能:结合注意力机制和单向 GRU 生成翻译。

  • 代码

    class RNNDecoder(FairseqIncrementalDecoder):
        def __init__(self, args, dictionary, embed_tokens):
            # 初始化解码器,继承自FairseqIncrementalDecoder
            super().__init__(dictionary)
            self.embed_tokens = embed_tokens  # 词嵌入层,将token ID映射为嵌入向量
            self.embed_dim = args.decoder_embed_dim  # 嵌入向量的维度,从参数中获取
            self.hidden_dim = args.decoder_ffn_embed_dim  # GRU隐藏层的维度,从参数中获取
            self.num_layers = args.decoder_layers  # GRU的层数,从参数中获取
            self.dropout_in_module = nn.Dropout(args.dropout)  # 输入dropout层
            self.rnn = nn.GRU(
                self.embed_dim,  # 输入维度,即词嵌入的维度
                self.hidden_dim,  # 隐藏层维度,控制GRU容量
                self.num_layers,  # GRU层数,决定深度
                dropout=args.dropout,  # dropout概率,防止过拟合
                batch_first=False,  # 输入形状(seq_len, batch, embed_dim)
                bidirectional=False  # 单向GRU,按序生成输出
            )
            self.attention = AttentionLayer(...)  # 注意力机制层,动态关注编码器输出
            self.dropout_out_module = nn.Dropout(args.dropout)  # 输出dropout层
            # 如果隐藏层维度与嵌入维度不一致,添加投影层
            if self.hidden_dim != self.embed_dim:
                self.project_out_dim = nn.Linear(self.hidden_dim, self.embed_dim)
            else:
                self.project_out_dim = None
            # 输出投影层,将隐藏状态映射到词汇表大小
            self.output_projection = nn.Linear(self.embed_dim, len(dictionary))
    
        def forward(self, prev_output_tokens, encoder_out, incremental_state=None, **unused):
            # 前向传播函数,生成目标语言输出
            # 从编码器获取输出、隐藏状态和padding mask
            encoder_outputs, encoder_hiddens, encoder_padding_mask = encoder_out
            if incremental_state is not None and len(incremental_state) > 0:
                # 如果有增量状态(用于推理),只取上一个时间步的输出
                prev_output_tokens = prev_output_tokens[:, -1:]
                cache_state = self.get_incremental_state(incremental_state, "cached_state")
                prev_hiddens = cache_state["prev_hiddens"]  # 获取缓存的隐藏状态
            else:
                # 训练时或推理的第一个时间步,使用编码器的隐藏状态
                prev_hiddens = encoder_hiddens
            bsz, seqlen = prev_output_tokens.size()  # 获取batch size和序列长度
            # 将token ID转换为嵌入向量
            x = self.embed_tokens(prev_output_tokens)  # 输出形状:(batch, seq_len, embed_dim)
            x = self.dropout_in_module(x)  # 在嵌入向量上应用dropout
            x = x.transpose(0, 1)  # 调整为(seq_len, batch, embed_dim),适配GRU
            # 应用注意力机制,动态关注编码器输出
            if self.attention is not None:
                x, attn = self.attention(x, encoder_outputs, encoder_padding_mask)
            # 通过单向GRU处理输入
            # - 输出x: (seq_len, batch, hidden_dim),每个时间步的隐藏状态
            # - final_hiddens: (num_layers, batch, hidden_dim),最终隐藏状态
            x, final_hiddens = self.rnn(x, prev_hiddens)
            x = self.dropout_out_module(x)  # 在GRU输出上应用dropout
            # 如果需要,投影到嵌入维度
            if self.project_out_dim is not None:
                x = self.project_out_dim(x)
            # 投影到词汇表大小,生成logits
            x = self.output_projection(x)  # 输出形状:(seq_len, batch, vocab_size)
            x = x.transpose(1, 0)  # 调整为(batch, seq_len, vocab_size),适配后续处理
            # 更新增量状态(用于推理)
            cache_state = {"prev_hiddens": final_hiddens}
            self.set_incremental_state(incremental_state, "cached_state", cache_state)
            return x, None  # 返回logits和额外信息(此处为None)
  • 通俗总结:这个“句子生成器”(解码器)负责把英文句子的“精华信息”翻译成中文。它先把已生成的中文词变成数字向量,然后用单向 GRU(一个能记住之前内容的神经网络)一步步生成新词。注意力机制像个“参考指南”,让解码器随时回头看英文句子的关键部分,确保翻译准确。最后,它输出每个词的概率,决定下一个中文词是什么。

3.1.3 Seq2Seq 模型
  • 功能:整合编码器和解码器。

  • 代码

    class Seq2Seq(FairseqEncoderDecoderModel):
        def __init__(self, args, encoder, decoder):
            # 初始化Seq2Seq模型,继承自FairseqEncoderDecoderModel
            super().__init__(encoder, decoder)
            self.args = args  # 保存参数对象,包含模型配置
            self.encoder = encoder  # 编码器实例
            self.decoder = decoder  # 解码器实例
    
        def forward(self, src_tokens, src_lengths, prev_output_tokens, return_all_hiddens: bool = True):
            # 前向传播函数,处理完整翻译过程
            # 编码器处理源语言输入
            # - src_tokens: 源语言token序列
            # - src_lengths: 每个序列的实际长度
            encoder_out = self.encoder(src_tokens, src_lengths=src_lengths, return_all_hiddens=return_all_hiddens)
            # 解码器处理目标语言输入和编码器输出
            # - prev_output_tokens: 前一时间步的目标语言token序列
            # - encoder_out: 编码器的输出
            logits, extra = self.decoder(
                prev_output_tokens,
                encoder_out=encoder_out,
                src_lengths=src_lengths,
                return_all_hiddens=return_all_hiddens
            )
            return logits, extra  # 返回logits(预测概率)和额外信息
  • 通俗总结:这个代码把编码器和解码器“组装”成一个完整的翻译机器。编码器先读懂英文句子,提取重要信息;解码器再根据这些信息生成中文句子。整个过程就像一个人先听懂一句外语,然后用自己的语言复述出来。

3.2 Transformer 模型

3.2.1 修改模型定义
  • 代码

    from fairseq.models.transformer import TransformerEncoder, TransformerDecoder
    # 使用Transformer编码器
    # - args: 模型参数
    # - src_dict: 源语言词典
    # - encoder_embed_tokens: 源语言词嵌入层
    encoder = TransformerEncoder(args, src_dict, encoder_embed_tokens)
    # 使用Transformer解码器
    # - args: 模型参数
    # - tgt_dict: 目标语言词典
    # - decoder_embed_tokens: 目标语言词嵌入层
    decoder = TransformerDecoder(args, tgt_dict, decoder_embed_tokens)
    # 构建Seq2Seq模型,整合编码器和解码器
    model = Seq2Seq(args, encoder, decoder)
  • 通俗总结:这段代码把模型从 RNN 升级成 Transformer。Transformer 像个更聪明的翻译员,不用按顺序读句子,而是同时看整个句子,通过“自注意力”机制快速抓住重点。编码器和解码器还是干老本行,但用 Transformer 的方式更快更准。

3.2.2 调整超参数
  • 代码

    arch_args = Namespace(
        encoder_embed_dim=512,  # 编码器嵌入维度,控制输入表示的容量
        encoder_ffn_embed_dim=2048,  # 编码器前馈网络维度,增加模型复杂度
        encoder_layers=4,  # 编码器层数,决定深度
        decoder_embed_dim=512,  # 解码器嵌入维度,与编码器对齐
        decoder_ffn_embed_dim=2048,  # 解码器前馈网络维度,增加容量
        decoder_layers=4,  # 解码器层数,决定深度
        encoder_attention_heads=8,  # 编码器注意力头数,提升并行处理能力
        decoder_attention_heads=8,  # 解码器注意力头数,提升并行处理能力
        dropout=0.3  # dropout概率,防止过拟合
    )
  • 通俗总结:这段代码是给 Transformer 模型“调参数”,就像调汽车引擎。把词向量的维度设为512,网络层数设为4层,注意力头数设为8个,增加模型的“脑容量”。还加了30%的 dropout,防止模型“死记硬背”,让它更灵活。

4. 训练和优化

4.1 损失函数(Label Smoothing)

  • 功能:加入平滑项防止过拟合。

  • 代码

    class LabelSmoothedCrossEntropyCriterion(nn.Module):
        def __init__(self, smoothing, ignore_index=None, reduce=True):
            # 初始化Label Smoothed Cross Entropy损失函数
            super().__init__()
            self.smoothing = smoothing  # 平滑参数,控制平滑程度
            self.ignore_index = ignore_index  # 忽略的标签ID(通常是padding)
            self.reduce = reduce  # 是否对损失进行reduce(求和或平均)
    
        def forward(self, lprobs, target):
            # 前向传播,计算损失
            # 如果target维度比lprobs少1维,调整target维度
            if target.dim() == lprobs.dim() - 1:
                target = target.unsqueeze(-1)  # 增加一维,与lprobs对齐
            # 计算NLL损失(负对数似然)
            # - gather从lprobs中提取target对应的概率
            nll_loss = -lprobs.gather(dim=-1, index=target)
            # 计算平滑损失
            # - 对所有类别的概率求和,作为平滑项
            smooth_loss = -lprobs.sum(dim=-1, keepdim=True)
            # 如果有忽略的标签,mask掉对应位置
            if self.ignore_index is not None:
                pad_mask = target.eq(self.ignore_index)  # 创建padding mask
                nll_loss.masked_fill_(pad_mask, 0.0)  # 将padding位置的损失置0
                smooth_loss.masked_fill_(pad_mask, 0.0)  # 将padding位置的平滑损失置0
            else:
                nll_loss = nll_loss.squeeze(-1)  # 移除多余维度
                smooth_loss = smooth_loss.squeeze(-1)  # 移除多余维度
            if self.reduce:
                nll_loss = nll_loss.sum()  # 对NLL损失求和
                smooth_loss = smooth_loss.sum()  # 对平滑损失求和
            # 计算最终损失
            # - eps_i: 平滑项的权重,smoothing除以词汇表大小
            # - 组合NLL损失和平滑损失
            eps_i = self.smoothing / lprobs.size(-1)
            loss = (1.0 - self.smoothing) * nll_loss + eps_i * smooth_loss
            return loss
  • 通俗总结:这段代码定义了模型的“评分标准”(损失函数)。它检查模型预测的中文词和正确答案的差距,算出“错误分数”。还加了“标签平滑”,让模型别太自信,给其他可能的词留点余地,防止它死记硬背答案,提高翻译的灵活性。

4.2 优化器(NoamOpt)

  • 功能:动态调整学习率。

  • 代码

    def get_rate(d_model, step_num, warmup_step):
        # Noam学习率调度公式
        # - d_model: 模型维度,用于缩放学习率
        # - step_num: 当前步数
        # - warmup_step: 预热步数,控制学习率初始增长
        # - 学习率先线性增加(step_num * warmup_step**(-1.5)),然后按步数的逆平方根衰减(step_num**(-0.5))
        lr = d_model ** (-0.5) * min(step_num ** (-0.5), step_num * warmup_step ** (-1.5))
        return lr
    
    class NoamOpt:
        def __init__(self, model_size, factor, warmup, optimizer):
            # 初始化Noam优化器
            self.optimizer = optimizer  # 底层优化器(如Adam)
            self._step = 0  # 当前步数,初始化为0
            self.warmup = warmup  # 预热步数,控制学习率增长阶段
            self.factor = factor  # 学习率缩放因子,调整整体学习率大小
            self.model_size = model_size  # 模型维度,用于学习率计算
            self._rate = 0  # 当前学习率,初始化为0
    
        def step(self):
            # 更新学习率并执行优化器步骤
            self._step += 1  # 步数加1
            rate = self.rate()  # 计算当前学习率
            # 更新底层优化器的学习率
            for p in self.optimizer.param_groups:
                p['lr'] = rate
            self._rate = rate  # 保存当前学习率
            self.optimizer.step()  # 执行优化器更新参数
    
        def rate(self, step=None):
            # 计算当前学习率
            if step is None:
                step = self._step  # 如果未提供步数,使用当前步数
            return self.factor * get_rate(self.model_size, step, self.warmup)  # 返回缩放后的学习率
  • 通俗总结:这段代码控制模型学习的“节奏”。它用一个叫 Noam 的方法动态调整学习率:刚开始学得慢点(预热),像热身运动;后面学得快点,但随着训练深入又慢慢放缓,防止学过头。就像教小孩,先慢慢教,熟练后再加速,最后小心调整。

4.3 训练循环

  • 功能:计算损失并更新参数。

  • 代码

    def train_one_epoch(epoch_itr, model, task, criterion, optimizer, accum_steps=1):
        # 训练一个epoch
        # - epoch_itr: 迭代器,提供每个epoch的数据
        # - model: 待训练的模型
        # - task: 任务对象,定义输入输出格式
        # - criterion: 损失函数
        # - optimizer: 优化器
        # - accum_steps: 梯度累积步数
        itr = epoch_itr.next_epoch_itr(shuffle=True)  # 获取数据迭代器,打乱数据顺序
        itr = iterators.GroupedIterator(itr, accum_steps)  # 分组迭代器,实现梯度累积
        stats = {"loss": []}  # 记录损失统计信息
        scaler = GradScaler()  # 自动混合精度缩放器,提升训练效率
        model.train()  # 设置模型为训练模式
        # 使用tqdm显示训练进度
        progress = tqdm.tqdm(itr, desc=f"train epoch {epoch_itr.epoch}", leave=False)
        for samples in progress:
            model.zero_grad()  # 清零模型梯度
            accum_loss = 0  # 计算累积损失初始化
            sample_size = 0  # 样本token数量初始化
            # 遍历累积步数内的样本
            for i, sample in enumerate(samples):
                if i == 1:
                    torch.cuda.empty_cache()  # 清理cuda缓存,释放显存
                sample = utils.move_to_cuda(sample, device=device)  # 将数据移动到GPU
                target = sample["target"]  # 获取目标token序列
                sample_size_i = sample["ntokens"]  # 获取当前样本的token数量
                sample_size += sample_size_i  # 累加token数量
                with autocast():  # 使用自动混合精度,节省显存并加速计算
                    net_output = model.forward(**sample["net_input"])  # 前向传播,计算模型输出
                    lprobs = F.log_softmax(net_output[0], -1)  # 计算log概率
                    # 计算损失,view(-1)将张量展平
                    loss = criterion(lprobs.view(-1, lprobs.size(-1)), target.view(-1))
                    accum_loss += loss.item()  # 累积损失值(标量)
                    scaler.scale(loss).backward()  # 缩放损失并反向传播
                scaler.step(optimizer)  # 更新模型参数
                scaler.update()  # 更新优化器状态
            scaler.unscale_(optimizer)  # 反缩放梯度,准备更新参数
            # 梯度
            optimizer.multiply_grads(1 / (sample_size orárv0.0))  # 梯度归一化,除以总token数,标准化更新幅度
            gnorm = nn.utils.clip_grad_norm_(model.parameters(), config.clip_norm)  # 梯度裁剪,防止梯度爆炸
            scaler.step(optimizer)  # 更新模型参数
            scaler.update()  # 更新缩放器状态
            loss_print = accum_loss / sample_size  # 计算平均损失
            stats["loss'].append(loss_print)  # 记录平均损失
            progress.set_postfix(loss=loss_print)  # 更新进度条显示
            # 如果使用wandb,记录训练日志
            if config.use_wandb:
                wandb.log({
                    "train/loss": loss_print,  # 平均损失
                    "train/grad_norm": gnorm.item(),  # 梯度范数
                    "train/lr": optimizer.rate(),  # 当前学习率
                    "train/sample_size": sample_size,  # 样本token数
                })
        loss_print = np.mean(stats["loss"])  # 计算整个epoch的平均损失
        logger.info(f"training loss: {loss_print:.4f}")  # 记录损失日志
        return stats  # 返回训练统计信息
  • 通俗总结:这段代码是模型的“学习过程”。它把数据分成小份喂给模型,模型预测后和正确答案对比,算出“错误分数”(损失)。然后调整模型的“知识点”,让错误更少。用了混合精度和GPU加速,像用高科技教机器;还加了梯度累积和裁剪,避免学得太猛,确保稳扎稳打。

5. 反向翻译(Back-translation)

5.1 训练反向语言

  • 修改配置

    # 修改配置以训练反向语言模型(中文到英文)
    config.source_lang = "zh"  # 设置源语言为中文
    config.target_lang = "en"  config.target_lang = "en"  # 设置目标语言为英文
    config.savedir = "./checkpoints/transformer-back"  # 设置模型检查点保存路径
  • 通俗总结:这段代码调整了训练方向,告诉模型这次要学“中文翻英文”,而不是“英文翻中文”。就像让一个翻译员练习反向翻译,专门为后面生成假数据做准备。

5.2 生成合成数据

下载单语数据
  • 代码

    # 下载中文单语数据
    # - 获取指定URL下载压缩文件
    # - -O 指定保存路径和文件名
    !wget https://github.com/yuhsinchan/ML2022-HW5Dataset/releases/download/v1.0.2/ted_zh_corpus.deduped.gz -O {output_prefix}/ted_zh_corpus.deduped.gz
    # 解压数据
    # - gzip -fkd 提取文件,保留源文件
    !gzip -d {output_prefix}/ted_zh_corpus.deduped.gz
  • 通俗总结:这段代码去网上抓了一堆只有中文的句子(没英文翻译),然后解压出来。这些中文句子是“额外教材”,用来造更多训练数据。

清洗单语数据
  • 代码

    def clean_mono_corpus(input_path, output_path, lang='zh'):
        # 清洗单语数据
        # - input_path: 输入文件路径
        # - output_path: 输出文件路径
        # - lang: 语言类型,默认为中文
        with open(input_path, 'r') as in_f:
            with open(output_path, 'w') as out_f:
                for line in in_f:
                    line = clean_s(line.strip(), lang)  # 使用clean_s清洗文本
                    # 控制句子长度在1到1000之间
                    if 1 <= len(line) <= 1000:
                        print(line, file=out_f)  # 写入清洗后的句子
    clean_mono_corpus(f'{output_prefix}/ted_zh_corpus.deduped', f'{output_prefix}/mono.clean.zh')
  • 通俗总结:这段代码把刚下载的中文句子再洗一遍,用 clean_s 清理杂乱部分(像去掉括号、规范标点)。还筛掉太长或太短的句子,留下的干净句子存到新文件里,准备下一步用。

分词
  • 代码

    # 加载SentencePiece模型
    spm_model = spm.SentencePieceProcessor(model_file=str(prefix/f'spm8000.model'))
    with open(f'{mono_prefix}/mono.tok.zh', 'w') as out_f:
        with open(f'{mono_prefix}/mono.clean.zh', 'r') as in_f:
            for line in in_f:
                # encode 将文本分割成subword,out_type=str 返回字符串列表
                tok = spm_model.encode(line.strip(), out_type=str)
                # 用空格连接subword并写入文件
                print(' '.join(tok), file=out_f)
  • 通俗总结:这段代码把中文句子切成小片段(subword),就像把“学习”切成“学”和“习”。用之前训练的 SentencePiece 模型把每个句子拆开,再用空格连起来存到文件,方便模型处理。

二值化
  • 代码

    # 使用fairseq对单语数据进行二值化
    # - 配置语言和数据路径
    # - --trainpref: 指定训练数据前缀
    # - --destdir: 指定保存目录
    # - --srcdict, --tgtdict: 指定字典文件
    !python -m fairseq_cli.preprocess \
        --source-lang 'zh' --target-lang 'en' \
        --trainpref {mono_prefix}/mono.tok \
        --destdir ./DATA/data-bin/mono \
        --srcdict ./DATA/data-bin/ted2020/dict.en.txt \
        --tgtdict ./DATA/data-bin/ted2020/dict.en.txt \
        --workers 2
  • 通俗总结:这段代码把切好的中文句子转成机器能快速读的二进制格式,就像把中文“笔记”压缩成电脑文件。用 fairseq 工具,指定中文到英文的词典,确保数据格式和之前的模型一致。

生成预测
  • 代码

    # 使用训练好的模型生成翻译预测
    # - model: 反向翻译模型
    # - task: 任务对象
    # - split: 数据集分割(这里为mono)
    # - outfile: 输出文件路径
    generate_prediction(model, task, split="mono", outfile="./prediction.txt")
  • 通俗总结:这段代码用“中文翻英文”的模型,把中文句子翻译成英文,存到文件里。就像请一个翻译员把中文“教材”翻译成英文,生成一堆假的英文句子,后面用来扩充数据。

5.3 创建新数据集

合并数据
  • 代码

    # 将预测结果(英文)进行分词并保存
    with open(f'{mono_prefix}/mono.tok.en', 'w') as out_f:
        with open('./prediction.txt', 'r') as in_f:
            for line in in_f:
                # 对预测的英文文本进行subword分词
                tok = spm_model.encode(line.strip(), out_type=str)
                # 用空格连接subword并写入文件
                print(' '.join(tok), file=out_f)
  • 通俗总结:这段代码把翻译出来的英文句子也切成小片段(subword),和中文处理一样。然后把这些片段存到文件,形成一组“中文-假英文”的句子对,准备加入训练数据。

二值化新数据
  • 代码

    # 定义二值化数据保存路径
    binpath = Path('./DATA/data-bin/synthetic')
    # 对合成数据进行二值化
    # - 配置参数
    # - --trainpref: 指定训练数据前缀
    # - --destdir: 保存路径
    # - --srcdict, --tgtdict: 指定字典文件
    !python -m fairseq_cli.preprocess \
        --source-lang 'zh' --target-lang 'en' \
        --trainpref {mono_prefix}/mono.tok \
        --destdir {binpath} \
        --srcdict ./DATA/data-bin/ted2020/dict.en.txt \
        --tgtdict ./data-bin/ted2020/dict.en.txt.gz \
        --workers 2
  • 通俗总结:这段代码把“中文-假英文”句子对转成二进制格式,存到新文件夹。就像把新造的翻译数据压缩好,确保格式和原来的数据一样,方便模型一起用。

整合到原始数据
  • 代码

    # 复制原始数据到新目录
    !cp -r ./DATA/data-bin/ted2020/ ./DATA/data-bin/ted2020_with_mono/
    # 将合成数据的英文二进制文件复制并重命名
    !cp ./DATA/data-bin/synthetic/train.zh-en.en.bin ./DATA/data-bin/ted2020_with_mono/train1.en-zh.en.bin
    # 类似地复制其他文件
  • 通俗总结:这段代码把假数据和原始数据“拼”在一起,像把新教材和旧教材装进一个大书包,确保模型能同时学到真翻译和假翻译的数据。

修改配置并训练
  • 代码

    # 配置使用包含合成数据的数据集
    config.datadir = "./DATA/data-bin/ted2020_with_mono"  # 数据目录
    config.source_lang = "en"  # 源语言为英文
    config.target_lang = "zh"  # 目标语言为中文
    config.savedir = "./checkpoints/transformer-bt"  # 检查点保存路径
  • 通俗总结:这段代码告诉模型“现在用新扩充的数据集(包含真假数据)重新学一遍英文到中文的翻译”,并指定保存路径。就像告诉学生用新课本复习,目标是翻译得更厉害。

6. 总结

  • RNN Seq2Seq:结构简单,依赖 GRU 逐步处理序列,适合基础翻译任务。

  • Transformer:用自注意力机制代替 GRU,能同时看整个句子,翻译更快更准。

  • 反向翻译:通过“造假数据”(用中文生成英文对),扩充训练材料,让模型学得更全面。


网站公告

今日签到

点亮在社区的每一天
去签到