Attention机制详解:从原理到中英翻译实战,让AI学会“划重点”

0 阅读15分钟

在自然语言处理领域,机器翻译一直是个经典难题。早期的翻译模型像一个“死记硬背”的学生:先把整篇中文课文背成一个固定长度的“小抄”,再凭着这张小抄默写出英文。句子短的时候效果还行,一旦句子变长,小抄装不下了,翻译就开始胡说八道。后来,科学家发明了Attention机制(注意力机制),让模型学会了“翻书”——翻译每个词的时候,都回头去原文里找最相关的地方看一眼。这一招,彻底改变了NLP的格局。

今天,我们就用最通俗的语言,配合完整的代码,把Attention机制彻底讲清楚。从它的设计初衷,到三种评分函数,再到一个可以直接跑起来的中英翻译项目,手把手带你拿下这个知识点。


一、传统Seq2Seq模型的两大硬伤

先复习一下没有Attention的老方法。经典的Seq2Seq模型(序列到序列模型)包含两个部分:

  • 编码器(Encoder)

    :读入源句子(比如中文),把整个句子的信息压缩成一个固定长度的向量,通常叫做“上下文向量”或“语义向量”。

  • 解码器(Decoder)

    :基于这个固定向量,一步一步生成目标句子(比如英文)。

这个过程就像你把一本小说浓缩成一张便利贴,然后让别人根据这张便利贴还原整本小说——信息丢失是必然的。具体来说,传统方法存在两个致命问题:

问题一:信息压缩困难
无论源句子多长,哪怕是100个词的复杂长句,最终都只能塞进一个固定大小的向量里。这导致长句的关键信息很容易被“挤丢”,模型翻译到后面时,前面的内容已经忘了。

问题二:缺乏动态感知
解码器在生成每个目标词时,用的是同一个静态向量。翻译“我”和翻译“散步”时,参考的信息源一模一样。这就像你写作文时不允许回看原文,只能凭印象写——开头还记得,结尾就开始编了。

为了解决这些问题,研究者提出了Attention机制。


二、Attention的核心思想:动态查阅

Attention机制的核心思想极其简单,却威力巨大:

解码器在生成目标序列的每一步时,不再依赖一个静态的上下文向量,而是根据当前的解码状态,动态地从编码器各时间步的隐藏状态中选取最相关的信息。

换句话说,解码器每生成一个词,都会重新“扫一遍”原文,用当前的“注意力”去加权提取原文中最有用的信息。这种机制让模型自动学会了对齐(Alignment)——它能判断出源句子中哪些位置与当前要生成的词关系最密切。

举个具体例子:翻译“我 爱 你” → “I love you”。当解码器要生成“love”时,注意力权重会集中在“爱”这个字上;生成“you”时,注意力会转向“你”。模型不需要任何人教,自己就学会了这种对应关系。


三、工作原理:一步一步拆解Attention计算

Attention机制的实现可以分成四个步骤。下面我们用最常用的点积评分函数来讲解。

3.1 计算注意力评分

第一步,我们需要衡量解码器当前隐藏状态编码器每个时间步的隐藏状态之间的相关性。这个相关性分数就叫注意力评分

假设解码器当前步的隐藏状态是 hdechdec(一个向量),编码器有T个时间步的隐藏状态 h1,h2,...,hTh1,h2,...,hT。最简单的评分方法就是计算点积:

scorei=hdec⋅hiscorei=hdec⋅hi

点积越大,说明两个向量方向越一致,相关性越强。如果你觉得“点积”不好理解,可以把它想象成两个向量的相似度——越像,分数越高。

3.2 归一化得到注意力权重

得到的分数大小不一,直接使用不方便。我们用一个Softmax函数把它们转换成概率分布,也就是注意力权重。Softmax的作用是:让所有分数变成0~1之间的数,并且加起来等于1。

αi=escorei∑j=1Tescorejαi=∑j=1Tescorejescorei

现在,αiαi 就代表了模型应该对第i个源位置投入多少“注意力”。分数越高的位置,权重越大。

3.3 生成上下文向量

有了注意力权重,我们就可以对编码器的所有隐藏状态进行加权求和,得到当前步的上下文向量

context=∑i=1Tαi⋅hicontext=i=1∑Tαi⋅hi

这个上下文向量就是模型从原文中提取的“重点笔记”——它融合了所有源位置的信息,但更强调当前最相关的部分。

3.4 解码信息融合

最后一步,解码器把当前步的GRU输出(hdechdec)和上下文向量拼接到一起,组成一个更丰富的信息向量,再通过一个线性层(全连接层)和Softmax,预测下一个词的概率分布。

combined=[hdec;context]combined=[hdec;context]output=softmax(W⋅combined+b)output=softmax(W⋅combined+b)

这样,解码器在预测每个词时,既用到了自己当前的记忆(hdechdec),也用到了从原文动态提取的外部信息(context),翻译效果自然大幅提升。


四、三种注意力评分函数,各有千秋

刚才我们用的是最简单的点积评分(Dot)。但实际应用中,编码器和解码器的隐藏状态维度可能不一样,或者任务复杂需要更强的表达能力。因此研究者提出了多种评分函数,常见的有三种:

4.1 点积评分(Dot)

通俗解释:直接计算两个向量的内积,值越大表示越相关。
优点:计算最快,实现简单。
缺点:要求 hdechdec 和 henchenc 维度相同,且表达能力有限。

4.2 通用点积评分(General)

通俗解释:先对编码器状态做一个线性变换(乘以矩阵W),把它的维度变成和解码器一致,然后再点积。
优点:可以处理维度不匹配的情况,W是可学习的,增加了模型灵活性。
缺点:比点积多了一些计算量。

4.3 拼接评分(Concat)/ 加性注意力

通俗解释:把解码器状态和编码器状态拼成一个长向量,扔进一个带激活函数的小型神经网络,最后用另一个向量v投影成一个分数。
优点:表达能力最强,因为引入了非线性变换(tanh),可以捕捉更复杂的交互关系。
缺点:计算量最大。

实际工程中,点积评分因为简单高效被广泛使用;在Transformer中,还使用了缩放点积注意力(除以维度的平方根,防止梯度消失)。我们的代码示例就采用最基本的点积评分,便于理解。


五、完整代码实战:中英翻译V2.0(带Attention)

理论讲完,我们上代码。下面是一个完整的中英翻译项目,在传统Seq2Seq基础上引入了Attention机制。你可以按顺序创建文件,然后运行训练和预测。

项目结构

translation_attention/
│
├── data/
│   ├── raw/                 # 存放原始数据 cmn.txt
│   └── processed/           # 预处理后的数据
├── models/                  # 保存训练好的模型
├── logs/                    # TensorBoard日志
└── src/
    ├── config.py
    ├── tokenizer.py
    ├── process.py
    ├── dataset.py
    ├── model.py
    ├── train.py
    ├── predict.py
    └── evaluate.py

5.1 配置文件 config.py

"""
配置文件:集中管理所有超参数和路径
"""
from pathlib import Path
 
# 项目根目录(src的父目录)
BASE_DIR = Path(__file__).parent.parent
 
# 数据路径
RAW_DATA_DIR = BASE_DIR / 'data' / 'raw'
PROCESSED_DATA_DIR = BASE_DIR / 'data' / 'processed'
MODELS_DIR = BASE_DIR / 'models'
LOGS_DIR = BASE_DIR / 'logs'
 
# 模型参数
EMBEDDING_DIM = 128          # 词向量维度
ENCODER_HIDDEN_DIM = 512     # 编码器GRU隐藏维度
DECODER_HIDDEN_DIM = 1024    # 解码器GRU隐藏维度(是编码器的2倍,因为编码器双向)
ENCODER_LAYERS = 1           # 编码器层数
 
# 训练参数
BATCH_SIZE = 128
SEQ_LEN = 30                 # 输入/输出序列最大长度
LEARNING_RATE = 1e-3
EPOCHS = 30

5.2 分词器 tokenizer.py

"""
分词器:中文按字切分,英文按词切分,提供编码/解码/词表构建功能
"""
from abc import abstractmethod
from nltk import word_tokenize, TreebankWordDetokenizer
from tqdm import tqdm
 
class BaseTokenizer:
    """分词器基类"""
    unk_token = '<unk>'   # 未知词
    pad_token = '<pad>'   # 填充符
    sos_token = '<sos>'   # 开始符
    eos_token = '<eos>'   # 结束符
 
    @staticmethod
    @abstractmethod
    def tokenize(sentence):
        pass
 
    @abstractmethod
    def decode(self, indexes):
        pass
 
    @classmethod
    def build_vocab(cls, sentences, vocab_file):
        """从句子列表构建词表并保存"""
        unique_words = set()
        for sentence in tqdm(sentences, desc='构建词表'):
            for word in cls.tokenize(sentence):
                unique_words.add(word)
        # 特殊标记放在最前面,保证索引固定
        vocab_list = [cls.pad_token, cls.unk_token, cls.sos_token, cls.eos_token] + list(unique_words)
        with open(vocab_file, 'w', encoding='utf-8') as f:
            for w in vocab_list:
                f.write(w + '\n')
 
    def __init__(self, vocab_list):
        self.vocab_list = vocab_list
        self.vocab_size = len(vocab_list)
        self.word2index = {w: i for i, w in enumerate(vocab_list)}
        self.index2word = {i: w for i, w in enumerate(vocab_list)}
        self.unk_token_index = self.word2index[self.unk_token]
        self.pad_token_index = self.word2index[self.pad_token]
        self.sos_token_index = self.word2index[self.sos_token]
        self.eos_token_index = self.word2index[self.eos_token]
 
    @classmethod
    def from_vocab(cls, vocab_file):
        with open(vocab_file, 'r', encoding='utf-8') as f:
            vocab_list = [line.strip() for line in f.readlines()]
        return cls(vocab_list)
 
    def encode(self, sentence, seq_len, add_sos_eos=False):
        """将句子转为索引序列,长度截断/补齐"""
        tokens = self.tokenize(sentence)
        indexes = [self.word2index.get(t, self.unk_token_index) for t in tokens]
        if add_sos_eos:
            indexes = indexes[:seq_len - 2]
            indexes = [self.sos_token_index] + indexes + [self.eos_token_index]
        else:
            indexes = indexes[:seq_len]
        if len(indexes) < seq_len:
            indexes += [self.pad_token_index] * (seq_len - len(indexes))
        return indexes
 
class ChineseTokenizer(BaseTokenizer):
    @staticmethod
    def tokenize(sentence):
        return list(sentence)   # 中文按字切分
    def decode(self, indexes):
        return ''.join([self.index2word[i] for i in indexes])
 
class EnglishTokenizer(BaseTokenizer):
    @staticmethod
    def tokenize(sentence):
        return word_tokenize(sentence)   # 英文按词切分
    def decode(self, indexes):
        tokens = [self.index2word[i] for i in indexes]
        return TreebankWordDetokenizer().detokenize(tokens)

5.3 数据预处理 process.py

"""
预处理原始中英文对齐文件,划分训练/测试集,构建词表,编码数据
"""
import pandas as pd
from sklearn.model_selection import train_test_split
from tokenizer import ChineseTokenizer, EnglishTokenizer
import config
 
def process():
    print("开始处理数据...")
    df = pd.read_csv(config.RAW_DATA_DIR / 'cmn.txt', sep='\t', header=None,
                     usecols=[0,1], names=['en','zh'])
    df = df.dropna()
    df = df[df['en'].str.strip().ne('') & df['zh'].str.strip().ne('')]
 
    train_df, test_df = train_test_split(df, test_size=0.2, random_state=42)
 
    # 构建词表
    EnglishTokenizer.build_vocab(train_df['en'].tolist(), config.PROCESSED_DATA_DIR / 'en_vocab.txt')
    ChineseTokenizer.build_vocab(train_df['zh'].tolist(), config.PROCESSED_DATA_DIR / 'zh_vocab.txt')
 
    en_tokenizer = EnglishTokenizer.from_vocab(config.PROCESSED_DATA_DIR / 'en_vocab.txt')
    zh_tokenizer = ChineseTokenizer.from_vocab(config.PROCESSED_DATA_DIR / 'zh_vocab.txt')
 
    # 编码训练集(英文加sos/eos,中文不加)
    train_df['en'] = train_df['en'].apply(
        lambda x: en_tokenizer.encode(x, config.SEQ_LEN, add_sos_eos=True))
    train_df['zh'] = train_df['zh'].apply(
        lambda x: zh_tokenizer.encode(x, config.SEQ_LEN, add_sos_eos=False))
    train_df.to_json(config.PROCESSED_DATA_DIR / 'indexed_train.jsonl', orient='records', lines=True)
 
    # 编码测试集
    test_df['en'] = test_df['en'].apply(
        lambda x: en_tokenizer.encode(x, config.SEQ_LEN, add_sos_eos=True))
    test_df['zh'] = test_df['zh'].apply(
        lambda x: zh_tokenizer.encode(x, config.SEQ_LEN, add_sos_eos=False))
    test_df.to_json(config.PROCESSED_DATA_DIR / 'indexed_test.jsonl', orient='records', lines=True)
 
    print("数据预处理完成!")
 
if __name__ == '__main__':
    process()

5.4 数据集 dataset.py

"""
PyTorch Dataset和DataLoader封装
"""
import pandas as pd
import torch
from torch.utils.data import Dataset, DataLoader
import config
 
class TranslationDataset(Dataset):
    def __init__(self, data_path):
        self.data = pd.read_json(data_path, lines=True).to_dict(orient='records')
    def __len__(self):
        return len(self.data)
    def __getitem__(self, idx):
        src = torch.tensor(self.data[idx]['zh'], dtype=torch.long)
        tgt = torch.tensor(self.data[idx]['en'], dtype=torch.long)
        return src, tgt
 
def get_dataloader(train=True):
    path = config.PROCESSED_DATA_DIR / ('indexed_train.jsonl' if train else 'indexed_test.jsonl')
    ds = TranslationDataset(path)
    return DataLoader(ds, batch_size=config.BATCH_SIZE, shuffle=train)

5.5 模型定义 model.py(核心:Attention模块)

"""
模型定义:编码器(双向GRU) + 注意力模块 + 解码器(单向GRU+Attention)
"""
import torch
import torch.nn as nn
import config
 
class Attention(nn.Module):
    """注意力机制模块(点积评分)"""
    def forward(self, decoder_hidden, encoder_outputs):
        """
        decoder_hidden: (1, batch, hidden_dim) 当前解码器隐藏状态
        encoder_outputs: (batch, seq_len, hidden_dim*2) 编码器所有输出
        返回 context: (batch, 1, hidden_dim)
        """
        # 步骤1:计算注意力分数 (batch, 1, seq_len)
        # 将 decoder_hidden 转置为 (batch, 1, hidden)
        # 将 encoder_outputs 转置为 (batch, hidden, seq_len)
        scores = torch.bmm(
            decoder_hidden.transpose(0, 1),   # (batch, 1, hidden)
            encoder_outputs.transpose(1, 2)   # (batch, hidden, seq_len)
        )
        # 步骤2:Softmax归一化得到权重
        weights = torch.softmax(scores, dim=2)   # (batch, 1, seq_len)
        # 步骤3:加权求和得到上下文向量
        context = torch.bmm(weights, encoder_outputs)  # (batch, 1, hidden)
        return context
 
class TranslationEncoder(nn.Module):
    """编码器:双向GRU"""
    def __init__(self, vocab_size, padding_idx):
        super().__init__()
        self.embedding = nn.Embedding(vocab_size, config.EMBEDDING_DIM, padding_idx=padding_idx)
        self.rnn = nn.GRU(config.EMBEDDING_DIM, config.ENCODER_HIDDEN_DIM,
                          num_layers=config.ENCODER_LAYERS,
                          batch_first=True, bidirectional=True)
    def forward(self, src):
        embedded = self.embedding(src)   # (batch, seq_len, embed_dim)
        outputs, hidden = self.rnn(embedded)
        # outputs: (batch, seq_len, hidden_dim*2)
        # hidden: (2*num_layers, batch, hidden_dim)
        return outputs, hidden
 
class TranslationDecoder(nn.Module):
    """解码器:单向GRU + Attention"""
    def __init__(self, vocab_size, padding_idx):
        super().__init__()
        self.embedding = nn.Embedding(vocab_size, config.EMBEDDING_DIM, padding_idx=padding_idx)
        self.rnn = nn.GRU(config.EMBEDDING_DIM, config.DECODER_HIDDEN_DIM, batch_first=True)
        self.attention = Attention()
        self.linear = nn.Linear(config.DECODER_HIDDEN_DIM * 2, vocab_size)
 
    def forward(self, tgt, hidden, encoder_outputs):
        """
        tgt: (batch, 1) 当前输入token
        hidden: (1, batch, decoder_hidden_dim) 上一步隐藏状态
        encoder_outputs: (batch, seq_len, encoder_hidden_dim*2)
        """
        embedded = self.embedding(tgt)                     # (batch, 1, embed_dim)
        output, new_hidden = self.rnn(embedded, hidden)    # output: (batch, 1, dec_hidden)
        context = self.attention(new_hidden, encoder_outputs)  # (batch, 1, dec_hidden)
        combined = torch.cat((output, context), dim=2)     # (batch, 1, dec_hidden*2)
        logits = self.linear(combined)                     # (batch, 1, vocab_size)
        return logits, new_hidden

5.6 训练脚本 train.py

"""
训练主程序:包含一个epoch的训练函数和主循环
"""
import time
from itertools import chain
import torch
import torch.nn as nn
from torch.utils.tensorboard import SummaryWriter
from tqdm import tqdm
from dataset import get_dataloader
from tokenizer import ChineseTokenizer, EnglishTokenizer
import config
from model import TranslationEncoder, TranslationDecoder
 
def train_one_epoch(dataloader, encoder, decoder, loss_fn, optimizer, device):
    encoder.train()
    decoder.train()
    total_loss = 0
    for src, tgt in tqdm(dataloader, desc="训练"):
        src = src.to(device)   # (batch, seq_len)
        tgt = tgt.to(device)   # (batch, seq_len)
 
        optimizer.zero_grad()
 
        # 编码器前向
        encoder_outputs, encoder_hidden = encoder(src)
        # encoder_hidden: (2, batch, enc_hidden) 因为双向
        # 将双向最后一层的两个方向拼接,作为解码器的初始隐藏状态
        forward_hidden = encoder_hidden[-2]   # (batch, enc_hidden)
        backward_hidden = encoder_hidden[-1]  # (batch, enc_hidden)
        decoder_hidden = torch.cat([forward_hidden, backward_hidden], dim=1)  # (batch, dec_hidden)
        decoder_hidden = decoder_hidden.unsqueeze(0)  # (1, batch, dec_hidden)
 
        # 解码器逐词预测
        decoder_input = tgt[:, 0:1]   # 第一个输入是 <sos>
        decoder_outputs = []
        for step in range(1, config.SEQ_LEN):
            logits, decoder_hidden = decoder(decoder_input, decoder_hidden, encoder_outputs)
            decoder_outputs.append(logits)
            decoder_input = tgt[:, step:step+1]   # teacher forcing
 
        decoder_outputs = torch.cat(decoder_outputs, dim=1)  # (batch, seq_len-1, vocab_size)
        targets = tgt[:, 1:]   # 去掉开头的<sos>
        loss = loss_fn(decoder_outputs.reshape(-1, decoder_outputs.shape[-1]), targets.reshape(-1))
        loss.backward()
        optimizer.step()
        total_loss += loss.item()
    return total_loss / len(dataloader)
 
def train():
    device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
    print(f"使用设备: {device}")
 
    train_loader = get_dataloader(train=True)
 
    zh_tokenizer = ChineseTokenizer.from_vocab(config.PROCESSED_DATA_DIR / 'zh_vocab.txt')
    en_tokenizer = EnglishTokenizer.from_vocab(config.PROCESSED_DATA_DIR / 'en_vocab.txt')
 
    encoder = TranslationEncoder(zh_tokenizer.vocab_size, zh_tokenizer.pad_token_index).to(device)
    decoder = TranslationDecoder(en_tokenizer.vocab_size, en_tokenizer.pad_token_index).to(device)
 
    loss_fn = nn.CrossEntropyLoss(ignore_index=en_tokenizer.pad_token_index)
    optimizer = torch.optim.Adam(chain(encoder.parameters(), decoder.parameters()), lr=config.LEARNING_RATE)
 
    writer = SummaryWriter(log_dir=config.LOGS_DIR / time.strftime('%Y-%m-%d_%H-%M-%S'))
    best_loss = float('inf')
    for epoch in range(1, config.EPOCHS+1):
        print(f"\n========== Epoch {epoch} ==========")
        avg_loss = train_one_epoch(train_loader, encoder, decoder, loss_fn, optimizer, device)
        print(f"平均损失: {avg_loss:.4f}")
        writer.add_scalar('Loss', avg_loss, epoch)
        if avg_loss < best_loss:
            best_loss = avg_loss
            torch.save(encoder.state_dict(), config.MODELS_DIR / 'encoder.pt')
            torch.save(decoder.state_dict(), config.MODELS_DIR / 'decoder.pt')
            print("保存最佳模型")
    writer.close()
 
if __name__ == '__main__':
    train()

5.7 预测脚本 predict.py

"""
交互式翻译预测:加载训练好的模型,对输入的中文句子进行翻译
"""
import torch
from tokenizer import ChineseTokenizer, EnglishTokenizer
from model import TranslationEncoder, TranslationDecoder
import config
 
def predict_batch(input_tensor, encoder, decoder, en_tokenizer, device):
    encoder.eval()
    decoder.eval()
    with torch.no_grad():
        encoder_outputs, encoder_hidden = encoder(input_tensor)
        # 初始化解码器隐藏状态
        forward_hidden = encoder_hidden[-2]
        backward_hidden = encoder_hidden[-1]
        decoder_hidden = torch.cat([forward_hidden, backward_hidden], dim=1).unsqueeze(0)
        batch_size = input_tensor.size(0)
        decoder_input = torch.full((batch_size, 1), en_tokenizer.sos_token_index, device=device)
        generated = [[] for _ in range(batch_size)]
        finished = [False] * batch_size
        for _ in range(1, config.SEQ_LEN):
            logits, decoder_hidden = decoder(decoder_input, decoder_hidden, encoder_outputs)
            preds = logits.argmax(dim=-1)  # (batch, 1)
            for i in range(batch_size):
                if finished[i]:
                    continue
                token_id = preds[i].item()
                if token_id == en_tokenizer.eos_token_index:
                    finished[i] = True
                else:
                    generated[i].append(token_id)
            if all(finished):
                break
            decoder_input = preds
        return generated
 
def predict_sentence(zh_sentence, encoder, decoder, zh_tokenizer, en_tokenizer, device):
    input_ids = zh_tokenizer.encode(zh_sentence, config.SEQ_LEN, add_sos_eos=False)
    input_tensor = torch.tensor([input_ids], device=device)
    gen = predict_batch(input_tensor, encoder, decoder, en_tokenizer, device)
    return en_tokenizer.decode(gen[0])
 
def run_predict():
    device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
    zh_tokenizer = ChineseTokenizer.from_vocab(config.PROCESSED_DATA_DIR / 'zh_vocab.txt')
    en_tokenizer = EnglishTokenizer.from_vocab(config.PROCESSED_DATA_DIR / 'en_vocab.txt')
 
    encoder = TranslationEncoder(zh_tokenizer.vocab_size, zh_tokenizer.pad_token_index).to(device)
    decoder = TranslationDecoder(en_tokenizer.vocab_size, en_tokenizer.pad_token_index).to(device)
    encoder.load_state_dict(torch.load(config.MODELS_DIR / 'encoder.pt', map_location=device))
    decoder.load_state_dict(torch.load(config.MODELS_DIR / 'decoder.pt', map_location=device))
 
    print("翻译系统已启动,输入中文句子(输入 q 退出):")
    while True:
        zh = input("中文: ").strip()
        if zh in ('q', 'quit'):
            break
        if not zh:
            continue
        en = predict_sentence(zh, encoder, decoder, zh_tokenizer, en_tokenizer, device)
        print(f"英文: {en}")
 
if __name__ == '__main__':
    run_predict()

5.8 评估脚本 evaluate.py

"""
使用BLEU分数评估模型性能
"""
import torch
from nltk.translate.bleu_score import corpus_bleu
from tqdm import tqdm
import config
from tokenizer import ChineseTokenizer, EnglishTokenizer
from model import TranslationEncoder, TranslationDecoder
from dataset import get_dataloader
from predict import predict_batch
 
def evaluate():
    device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
    zh_tokenizer = ChineseTokenizer.from_vocab(config.PROCESSED_DATA_DIR / 'zh_vocab.txt')
    en_tokenizer = EnglishTokenizer.from_vocab(config.PROCESSED_DATA_DIR / 'en_vocab.txt')
 
    encoder = TranslationEncoder(zh_tokenizer.vocab_size, zh_tokenizer.pad_token_index).to(device)
    decoder = TranslationDecoder(en_tokenizer.vocab_size, en_tokenizer.pad_token_index).to(device)
    encoder.load_state_dict(torch.load(config.MODELS_DIR / 'encoder.pt', map_location=device))
    decoder.load_state_dict(torch.load(config.MODELS_DIR / 'decoder.pt', map_location=device))
 
    test_loader = get_dataloader(train=False)
    refs = []
    hyps = []
    specials = {zh_tokenizer.pad_token_index, zh_tokenizer.sos_token_index, zh_tokenizer.eos_token_index}
    for src, tgt in tqdm(test_loader, desc="评估"):
        src = src.to(device)
        pred_idxs = predict_batch(src, encoder, decoder, en_tokenizer, device)
        hyps.extend(pred_idxs)
        for seq in tgt:
            filtered = [idx.item() for idx in seq if idx.item() not in specials]
            refs.append([filtered])
    bleu = corpus_bleu(refs, hyps)
    print(f"\nBLEU-4 分数: {bleu:.4f}")
 
if __name__ == '__main__':
    evaluate()

六、运行你的翻译模型

  1. 下载数据集:pan.baidu.com/s/1As2fpzjO…

  2. 依次运行:python src/process.py python src/train.py python src/predict.py

  3. 训练大约30个epoch后,模型会保存在 models/ 目录下。然后运行 predict.py 就可以交互式翻译了。

完整代码下载:pan.baidu.com/s/10pbHn5UW…


七、Attention机制的两大遗留问题

尽管Attention机制极大地提升了Seq2Seq的能力,但它仍然建立在RNN(或GRU/LSTM)的基础之上,因此无法避免RNN固有的缺陷:

问题一:计算过程无法并行
RNN必须按时间步顺序执行:计算第t步需要第t-1步的输出。这种强依赖关系导致GPU的并行能力无法充分发挥,训练速度慢,尤其对于长序列更是如此。

问题二:长期依赖问题仍未根除
虽然Attention让解码器可以直接“看到”所有编码器状态,但编码器内部仍然要靠RNN一步步传递信息。对于超长序列(比如几百个词的句子),RNN依然会面临梯度消失或梯度爆炸,难以真正捕捉远距离依赖。

正是为了解决这两个问题,Google在2017年提出了Transformer架构——彻底抛弃RNN,完全基于自注意力(Self-Attention) 来建模。Transformer通过多头注意力和位置编码,实现了全局并行计算和超长距离依赖建模,成为了今天GPT、BERT等大模型的基石。


八、总结与思考

从最初“死记硬背”的固定向量,到“动态查阅”的注意力机制,再到彻底革命的自注意力Transformer,机器翻译乃至整个NLP领域经历了一场深刻的思维变革。

Attention机制的核心贡献在于:它让模型学会了选择性关注,不再平等对待所有输入信息,而是根据当前任务需求动态分配计算资源。这种思想不仅适用于机器翻译,在图像描述、语音识别、推荐系统等领域也同样大放异彩。

通过本文的代码实战,你可以亲手体验Attention的力量:即使是一个简单的GRU模型,加上注意力模块后,翻译长句的流畅度和准确度都会有肉眼可见的提升。而如果你有兴趣,还可以进一步尝试替换评分函数(比如改成General或Concat),或者实现Beam Search解码,看看能带来多少改善。

Attention机制不是终点,但它无疑是通向现代深度学习的一扇重要大门。希望这篇文章能帮你把这扇门推开,看到更广阔的风景。