首页 / PyTorch 入门教程 / seq2seq 与机器翻译

PyTorch 入门教程

seq2seq 与机器翻译

本教程共 60 篇 · 第 43 篇 · 更新于 2026-08-17 · 约 4 分钟阅读

PyTorchseq2seq机器翻译编码器-解码器注意力机制Teacher ForcingGRU

本节目标:理解编码器-解码器(Encoder-Decoder)结构如何解决变长到变长的翻译问题,明白注意力(Attention)为什么出现,并看懂带注意力的 seq2seq 训练与解码流程。

上一章的字符级 RNN 生成名字,输入输出都是字符。机器翻译更复杂:输入是法语,输出是英语,两边长度不一样,词的顺序还可能相反。比如法语「je ne suis pas le chat noir」直译是「我 不 是 那 猫 黑」,英语却要说「我不是黑猫」。

逐个词对齐翻译行不通。序列到序列(Sequence to Sequence,seq2seq)的思路是:先整句读进去,理解了再整句说出来。

编码器:把句子压成一个向量

seq2seq 由两个 RNN 组成。第一个叫编码器(Encoder):一个词一个词读输入句子,每读一步更新隐状态。读完后,最后的隐状态浓缩了整句的信息,叫上下文向量(Context Vector)。

编码器的输入是词索引序列,先过第 41 章的词嵌入层变成向量,再喂 RNN。batch 为 1 时,每个时间步的形状是 (1, 1, hidden_size)。

解码器:从向量里展开句子

第二个叫解码器(Decoder):从上下文向量出发,一个词一个词生成输出。开头先给一个特殊标记 SOS(Start of Sentence,句子开始),每步预测下一个词,直到吐出 EOS 为止。

打个比方:编码器是读者,把法语句子读成一段「摘要」;解码器是作家,凭摘要写英语句子。

简单解码器的短板:信息瓶颈

摘要只能记大概。句子一长,一个固定大小的向量装不下所有细节,翻译质量明显下滑。这叫信息瓶颈,是朴素 seq2seq 的硬伤。

注意力:翻到哪,看哪

注意力机制(Attention)给解码器加了一个能力:生成每个词时,回头扫一遍编码器所有时间步的输出,按相关度加权,取回最需要的信息。

具体分三步:

  1. 用解码器当前状态和每个编码器输出算相关度分数。
  2. softmax 归一化,得到一组注意力权重(Attention Weights)。
  3. 权重和编码器输出加权求和,得到这一步的「上下文」,喂给解码器。

关键区别在这:信息来自编码器每一步的输出,而不是最后那一个固定向量,信息瓶颈就此解开。

翻译「black cat」时,生成 cat 那一步的权重会集中在法语的 noir 上。注意力矩阵画出来,能看到一条漂亮的斜对角线——两种语言的对齐关系一目了然。

数据准备:先建两个词表

翻译要建两个词表:法语一个、英语一个。教程用 Lang 类维护双向映射,并预置两个特殊标记:SOS(句子开始,索引 0)和 EOS(句子结束,索引 1)。

SOS_token = 0
EOS_token = 1

class Lang:
    def __init__(self, name):
        self.name = name
        self.word2index = {}
        self.index2word = {0: "SOS", 1: "EOS"}
        self.n_words = 2  # 两个特殊标记占前两个位置

    def add_sentence(self, sentence):
        for word in sentence.split(" "):
            if word not in self.word2index:
                self.word2index[word] = self.n_words
                self.index2word[self.n_words] = word
                self.n_words += 1

lang = Lang("fra")
lang.add_sentence("je suis la")
print(lang.n_words)  # 5

句子转张量时,末尾要追加 EOS 索引,解码器才学得会「什么时候停」。

PyTorch 里的实现

编码器很简洁:词嵌入 + GRU,返回每个时间步的输出和最终隐状态。

import torch
import torch.nn as nn

class EncoderRNN(nn.Module):
    def __init__(self, input_size, hidden_size):
        super().__init__()
        self.hidden_size = hidden_size
        self.embedding = nn.Embedding(input_size, hidden_size)
        self.gru = nn.GRU(hidden_size, hidden_size)

    def forward(self, x, hidden):
        embedded = self.embedding(x).view(1, 1, -1)
        output, hidden = self.gru(embedded, hidden)
        return output, hidden

    def init_hidden(self):
        return torch.zeros(1, 1, self.hidden_size)

带注意力的解码器也不难:注意力权重由一个线性层算出来,用 torch.bmm 做加权求和。

class AttnDecoderRNN(nn.Module):
    def __init__(self, hidden_size, output_size, max_length=10):
        super().__init__()
        self.embedding = nn.Embedding(output_size, hidden_size)
        self.attn = nn.Linear(hidden_size * 2, max_length)
        self.attn_combine = nn.Linear(hidden_size * 2, hidden_size)
        self.gru = nn.GRU(hidden_size, hidden_size)
        self.out = nn.Linear(hidden_size, output_size)

    def forward(self, x, hidden, encoder_outputs):
        embedded = self.embedding(x).view(1, 1, -1)
        attn_weights = torch.softmax(
            self.attn(torch.cat((embedded[0], hidden[0]), 1)), dim=1)
        attn_applied = torch.bmm(attn_weights.unsqueeze(0),
                                 encoder_outputs.unsqueeze(0))
        output = torch.cat((embedded[0], attn_applied[0]), 1)
        output = self.attn_combine(output).unsqueeze(0)
        output, hidden = self.gru(torch.relu(output), hidden)
        return torch.log_softmax(self.out(output[0]), dim=1), hidden, attn_weights
Note

max_length 是训练前设定的最长句子长度,编码器输出张量按它预留,短句只占前面几行。这是早期教程的简化做法,后来 Transformer 的 SDPA 注意力不再需要这个限制。

Tip

训练时解码器每步都要拿编码器的全部输出,所以流程是:编码器先把整句跑完、把每个时间步的输出都存起来,再开始解码。batch 为 1 时形状是 (max_length, hidden_size)。

Teacher Forcing:训练时的「参考答案」

训练解码器有个选择:下一步的输入,用真实答案还是模型自己的预测?

用真实答案叫教师强制(Teacher Forcing):收敛快,像有人扶着学骑车。但扶习惯了,模型没机会纠正自己的错误,推理时可能越错越离谱。标准做法是设一个比例,比如 50% 概率用真实答案,其余时间用自己的预测。

teacher_forcing_ratio = 0.5

if random.random() < teacher_forcing_ratio:
    decoder_input = target[di]      # 教师强制:喂真实答案
else:
    decoder_input = topi.detach()   # 自己的预测,detach 断开梯度

数据与评估

翻译数据是成千上万句「法语-英语」对照对,官方教程用的数据集由 Tatoeba 项目整理、可从 manythings.org 下载。预处理包括:统一转 ASCII、小写、去标点,再过滤掉超过 10 个词的长句,只留「i am / he is / she is」开头的高频句式。约 13.6 万句筛完剩 1 万句左右,词表各几千词,CPU 上几十分钟就能训完。

评估时没有标准答案,解码器只能用自己的预测一路滚下去:每步取概率最大的词,碰到 EOS 停。这就是贪心解码(Greedy Decoding)。想要更好的结果,可以用束搜索(Beam Search):每步多留几个候选,最后挑整体概率最高的句子。beam 宽度越大效果越好,代价是计算量,工程上通常取 4~8。

训练 7.5 万次迭代后,随便抽几句评估:

> je pars en vacances pour quelques jours .
= i m taking a couple of days off .
< i m taking a couple of days off .

> je suis fou de vous .
= i m crazy about you .
< i m crazy about you .

短句翻得相当准,长句开始露馅——这正是信息瓶颈的直观体现,也是注意力要解决的痛点。

Tip

评估过程用 torch.no_grad() 包住。注意力权重记得存下来,plt.matshow(attentions) 画出来,是验证模型有没有学会对齐的直观证据。

从 RNN 到 Transformer

今天的主流翻译模型早已换成 Transformer(第 23 章):编码器-解码器结构没变,但「读」从逐步接力变成并行自注意力。seq2seq 里的编码器输出、注意力加权这些概念,在 Transformer 里原样保留。现在的翻译工具界面也还是一样:一句进去,一句出来,中间隔着注意力。学懂这一章,大模型翻译的原理就通了一大半。

本章一句话:编码器读进去压成向量,解码器吐出来展开成句,注意力让解码器每步都能回看原文——变长到变长的翻译问题,就这样被拆解掉了。