,

3. NLP From Scratch:Translation with a Sequence to Sequence Network and Attention

在这个部分我们将会学习如何利用神经网络将文本进行翻译,这部分的内容具有一定的难度和深度,主要是网络结构变得更加复杂,请一定要耐心做完。

我们需要创建一个从序列到序列的神经网络,从而需要两个RNN网络来完成这件事,首先构建一个名为“Encoder”(编码器)的RNN,完成将一句话转化为一个向量,再构建一个名为“ Decoder”(解码器)的RNN,将Encoder生成的向量转化为一句话,这就是我们的工作流程。同时为了提高模型的能力,我们在此添加了注意力机制

squ2squ.png

Requirements

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from __future__ import unicode_literals, print_function, division
from io import open
import unicodedata
import re
import random

import torch
import torch.nn as nn
from torch import optim
import torch.nn.functional as F

import numpy as np
from torch.utils.data import TensorDataset, DataLoader, RandomSampler
%matplotlib inline

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

Loading data files

我们在前一部分下载的data文件夹下面有eng-fra.txt,文件里面具有英语到法语的对应字段。为了将语句输入到神经网络中,我们需要构建一个类Lang来帮助我们构建一个字典,实现word->index(word2index) and index->word(index2word),同时我们使用word2count来记录单词出现的次数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# 定义句子开始和结束的标记符(Start Of Sentence 和 End Of Sentence)
SOS_token = 0
EOS_token = 1

class Lang:
def __init__(self, name):
self.name = name # 语言名称(如"English"或"French")
self.word2index = {} # 单词到索引的映射字典(如{"hello": 2})
self.word2count = {} # 单词出现次数的统计字典(如{"hello": 5})
self.index2word = {0: "SOS", 1: "EOS"} # 索引到单词的映射字典(保留0和1给SOS/EOS)
self.n_words = 2 # 当前词汇表大小(初始为2,因为已经包含SOS和EOS)

def addSentence(self, sentence):
# 将句子分割成单词,并逐个添加到词汇表中
for word in sentence.split(' '): # 按空格分割句子
self.addWord(word) # 调用addWord方法添加每个单词

def addWord(self, word):
# 添加单个单词到词汇表
if word not in self.word2index: # 如果单词是新词(未在词汇表中)
self.word2index[word] = self.n_words # 为新词分配下一个可用索引
self.word2count[word] = 1 # 初始化该单词的计数为1
self.index2word[self.n_words] = word # 建立反向映射(索引→单词)
self.n_words += 1 # 增加词汇表计数
else: # 如果单词已存在
self.word2count[word] += 1 # 只增加该单词的计数

接下来是常规的进行字符处理,处理成符合ascii的字符类型,稍微看看就行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# Turn a Unicode string to plain ASCII, thanks to
# https://stackoverflow.com/a/518232/2809427
def unicodeToAscii(s):
return ''.join(
c for c in unicodedata.normalize('NFD', s)
if unicodedata.category(c) != 'Mn'
)

# Lowercase, trim, and remove non-letter characters
def normalizeString(s):
s = unicodeToAscii(s.lower().strip())
s = re.sub(r"([.!?])", r" \1", s)
s = re.sub(r"[^a-zA-Z!?]+", r" ", s)
return s.strip()

从文件读取两种语言,并用一个reverse标志符号确实是从哪个语言翻译为另一个语言,代码很简单,相信大家都能看懂:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
def readLangs(lang1, lang2, reverse=False):
"""读取双语文本文件,处理并返回语言对象和句子对"""

print("Reading lines...")

# 读取文件并分割成行
lines = open('data/%s-%s.txt' % (lang1, lang2), encoding='utf-8').read().strip().split('\n')

# 将每行分割成句子对并进行标准化处理
pairs = [[normalizeString(s) for s in l.split('\t')] for l in lines]

# 根据reverse参数决定语言方向
if reverse:
pairs = [list(reversed(p)) for p in pairs]
input_lang = Lang(lang2)
output_lang = Lang(lang1)
else:
input_lang = Lang(lang1)
output_lang = Lang(lang2)

return input_lang, output_lang, pairs

语句的长度大多数是长短不一样的,在这里我们为了让模型训练速度更快,使用最大长度为10个单词的语句,同时英语语句的开头必须符合主谓结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
MAX_LENGTH = 10

eng_prefixes = (
"i am ", "i m ",
"he is", "he s ",
"she is", "she s ",
"you are", "you re ",
"we are", "we re ",
"they are", "they re "
)

# 返回过滤句子对:确保两个句子长度都小于MAX_LENGTH且目标句子以特定前缀开头,
def filterPair(p):
return len(p[0].split(' ')) < MAX_LENGTH and \
len(p[1].split(' ')) < MAX_LENGTH and \
p[1].startswith(eng_prefixes)

# 过滤所有句子对,只保留符合filterPair条件的
def filterPairs(pairs):
return [pair for pair in pairs if filterPair(pair)]

数据准备的过程:

  1. 读取文件并且将文本内容转化为句子对(pairs)
  2. 规范化文本,按长度和开头语句过滤
  3. 用句子对形成 word list
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
def prepareData(lang1, lang2, reverse=False):
"""准备训练数据:读取数据、过滤句子对、构建词汇表"""
# 1. 读取原始双语数据
input_lang, output_lang, pairs = readLangs(lang1, lang2, reverse)
print("Read %s sentence pairs" % len(pairs))

# 2. 过滤过长的句子和不符合格式的句子
pairs = filterPairs(pairs)
print("Trimmed to %s sentence pairs" % len(pairs))

# 3. 构建两种语言的词汇表
print("Counting words...")
for pair in pairs:
input_lang.addSentence(pair[0]) # 将源语言句子加入词汇表
output_lang.addSentence(pair[1]) # 将目标语言句子加入词汇表

# 4. 打印词汇表信息
print("Counted words:")
print(input_lang.name, input_lang.n_words)
print(output_lang.name, output_lang.n_words)

return input_lang, output_lang, pairs

# 使用示例:准备英法翻译数据(反向模式)
input_lang, output_lang, pairs = prepareData('eng', 'fra', True)
print(random.choice(pairs)) # 随机打印一个预处理后的句子对

The Seq2Seq Model(Important!)

RNN是一个在序列上运行并使用自己的输出作为后续步骤的输入的网络;seq2seq网络,或编码器解码器网络,是一个由两个RNN组成的模型,称为编码器和解码器。编码器读取输入序列并输出单个向量,解码器读取该向量以产生输出序列。

与单个RNN的序列预测不同,每个输入都对应一个输出,seq2seq模型将我们从序列长度和顺序中解放出来,这使得它非常适合两种语言之间的翻译。

考虑句子Je ne suis pas le chat noir → I am not the black cat输入句子中的大多数单词在输出句子中有直接翻译,但顺序略有不同,例如chat noir 和 black cat。由于ne/pas结构,输入句子中还有一个单词。很难直接从输入的单词序列中产生正确的翻译。

使用seq2seq模型,编码器创建了一个单一向量,在理想情况下,该向量将输入序列的“含义”编码为单个向量——一些N维句子空间中的单点。模型的结构如下

模型结构

The Encoder

编码器的结构如图所示:
encoder.png

构建一个Encoder类,其中 embedding 层是用于将单词的索引转化成一个连续的向量(思考为什么不是转化为 one-hot 向量),gru 层是 RNN 网络的一种变体,dropout 层用来防止过拟合;正向传播的过程可以结合图示更加清晰。

1
2
3
4
5
6
7
8
9
10
11
12
13
class EncoderRNN(nn.Module):
def __init__(self, input_size, hidden_size, dropout_p=0.1):
super(EncoderRNN, self).__init__()
self.hidden_size = hidden_size

self.embedding = nn.Embedding(input_size, hidden_size)
self.gru = nn.GRU(hidden_size, hidden_size, batch_first=True)
self.dropout = nn.Dropout(dropout_p)

def forward(self, input):
embedded = self.dropout(self.embedding(input))
output, hidden = self.gru(embedded)
return output, hidden

The Decoder

Simple Decoder

在最简单的seq2seq解码器中,我们只使用编码器的最后一个输出。最后一个输出有时被称为上下文向量(context vector),因为它对整个序列的上下文进行编码。此上下文向量用作解码器的初始隐藏状态。

在解码的每个步骤中,解码器都会得到一个输入令牌和隐藏状态。初始输入令牌是字符串开头<SOS>令牌,第一个隐藏状态是上下文向量,同样是编码器的最后一个隐藏状态,这也是为什么两个网络能够结合起来发挥能力的重要原因。结构如图:
simpledecoder.png

同样我们在解码器中定义了 embeddind 层,gru 层。前向传播过程中的代码稍微多一点,因为一个句子会有很多单词,而我们需要单词一个一个的预测。我们添加了target_tensor参数,主要作用是在训练的过程中,我们将真实的单词作为下一个输入,而在测试的时候我们将当前预测的单词作为下一个输入。前向传播的过程是我们从编码器获得隐藏状态,然后在循环中进行当前单词的预测,循环结构将每一步预测的单词拼接起来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
class DecoderRNN(nn.Module):
"""基于GRU的RNN解码器,用于序列生成任务(如机器翻译)"""

def __init__(self, hidden_size, output_size):
"""
初始化解码器
:param hidden_size: 隐藏层维度(与编码器保持一致)
:param output_size: 输出词汇表大小(目标语言词表大小)
"""
super(DecoderRNN, self).__init__()
# 词嵌入层:将目标语言的单词索引映射为稠密向量
self.embedding = nn.Embedding(output_size, hidden_size)
# GRU层:处理序列生成(输入和隐藏维度相同)
self.gru = nn.GRU(hidden_size, hidden_size, batch_first=True)
# 输出层:将隐藏状态映射到目标词表空间
self.out = nn.Linear(hidden_size, output_size)

def forward(self, encoder_outputs, encoder_hidden, target_tensor=None):
"""
前向传播(生成目标序列)
:param encoder_outputs: 编码器输出(通常未直接使用)
:param encoder_hidden: 编码器的最终隐藏状态(作为解码器初始状态)
:param target_tensor: 目标序列(用于Teacher Forcing训练模式)
:return: (输出序列概率, 最终隐藏状态, None)
"""
# 初始化:输入为<SOS>标记,隐藏状态继承自编码器
batch_size = encoder_outputs.size(0)
decoder_input = torch.empty(batch_size, 1, dtype=torch.long, device=device).fill_(SOS_token)
decoder_hidden = encoder_hidden
decoder_outputs = [] # 保存每一步的输出

# 循环生成序列(最多MAX_LENGTH步)
for i in range(MAX_LENGTH):
# 单步解码:输入当前词和隐藏状态,得到输出和新状态
decoder_output, decoder_hidden = self.forward_step(decoder_input, decoder_hidden)
decoder_outputs.append(decoder_output)

# 决定下一步的输入(训练和预测模式不同)
if target_tensor is not None:
# Teacher Forcing模式:使用真实目标词作为下一步输入
decoder_input = target_tensor[:, i].unsqueeze(1)
else:
# 预测模式:使用模型预测的概率最高词作为下一步输入
_, topi = decoder_output.topk(1) # 取概率最大的词索引
decoder_input = topi.squeeze(-1).detach() # 切断梯度反向传播

# 后处理:拼接所有输出并计算log softmax
decoder_outputs = torch.cat(decoder_outputs, dim=1)
decoder_outputs = F.log_softmax(decoder_outputs, dim=-1)
return decoder_outputs, decoder_hidden, None # 返回None保持训练接口统一

def forward_step(self, input, hidden):
"""单步解码过程"""
output = self.embedding(input) # 词嵌入
output = F.relu(output) # 激活函数
output, hidden = self.gru(output, hidden) # GRU处理
output = self.out(output) # 映射到词表空间
return output, hidden

Attention Decoder

如果只有上下文向量在编码器和解码器之间传递,则该单个向量承担了编码整个句子的负担。

注意力机制允许解码器网络在解码器自身输出的每个步骤中“专注于”编码器输出的不同部分。首先,我们计算一组注意力权重。这些将乘以编码器输出向量,以创建一个加权组合。结果(在代码中称为attn_applied)应包含有关输入序列特定部分的信息,从而帮助解码器选择正确的输出词。计算注意力权重是用另一个前馈层 attn 完成的,使用解码器的输入和隐藏状态作为输入。由于训练数据中包含各种大小的句子,为了实际创建和训练此层,我们必须选择可以应用的最大句子长度(输入长度,用于编码器输出)。最大长度的句子将使用所有注意力权重,而较短的句子将只使用前几个。解码器的结构如下图:
attn.png

attn2.png

Bahdanau注意力,也称为 additive attention,是 Sqe2Sqe 模型中常用的注意力机制,特别是在神经机器翻译任务中。这种注意力机制采用学习的对齐模型来计算编码器和解码器隐藏状态之间的注意力分数。它利用前馈神经网络来计算对齐分数。

然而,还有可用的替代注意力机制,例如Luong注意力,它通过在解码器隐藏状态和编码器隐藏状态之间取得分来计算注意力分数。它不涉及Bahdanau注意力中使用的非线性变换。

在本教程中,我们将使用Bahdanau的注意力。然而,探索修改注意力机制以使用Luong注意力将是一项有价值的练习。

在Bahdanau注意力机制中我们定义了三个线性变换层:

线性层 输入 输出 功能说明
Wa 解码器的当前隐藏状态 (query) [batch_size, 1, hidden_size] 将解码器的隐藏状态映射到一个新的空间,用于与编码器输出计算匹配度
Ua 编码器的所有输出 (keys) [batch_size, seq_len, hidden_size] 将编码器的每个时间步输出映射到与Wa(query)相同的空间,便于计算交互作用
Va 加和后的中间结果 [batch_size, seq_len, 1] 将交互结果压缩为一个标量分数(注意力分数),再通过softmax归一化为权重

前向传播过程:

  1. Wa(query)
    解码器的当前隐藏状态(query)经过线性变换,维度不变,但映射到与编码器输出对齐的空间。
  2. Ua(keys)
    编码器的所有输出(keys)分别经过线性变换,维度不变,但映射到与解码器状态对齐的空间。
  3. 相加 + tanh
    • Wa(query)广播(复制)到与Ua(keys)相同的序列长度,然后逐元素相加。
    • 通过tanh激活函数引入非线性,增强表达能力。
  4. Va 计算分数
    将相加后的结果通过Va映射为一个标量分数(即注意力分数),表示解码器当前步对编码器某位置的关注程度。

在注意力解码器中,在保留其他层的条件下,我们将上述构建的BahdanauAttention 添加到网络中。在前向传播中整体与非注意力机制的传播过程相同,多了一个attention参数,更重要的是在 单步前向传播过程中的计算:
这个 forward_step 方法是带有注意力机制的RNN解码器的核心操作,它定义了解码器单步生成的过程。下面我将逐步解析每一部分的实现和作用:

1. 输入嵌入 (Input Embedding)

1
embedded = self.dropout(self.embedding(input))
  • 输入input 是当前时间步的解码器输入(单词索引),形状为 [batch_size, 1]
  • 处理
    • self.embedding:将单词索引转换为稠密向量([batch_size, 1, hidden_size]
    • self.dropout:随机屏蔽部分神经元,防止过拟合(训练时生效)
  • 输出embedded 是当前词的嵌入表示,形状 [batch_size, 1, hidden_size]

2. 注意力计算 (Attention Mechanism)

1
2
query = hidden.permute(1, 0, 2)
context, attn_weights = self.attention(query, encoder_outputs)
  • query 调整
    • hidden 是解码器上一时间步的隐藏状态,原始形状为 [num_layers, batch_size, hidden_size](GRU的默认输出格式)
    • permute(1, 0, 2) 将其调整为 [batch_size, 1, hidden_size] 以匹配注意力层的输入要求
  • 注意力计算
    • self.attention(query, encoder_outputs) 调用 BahdanauAttention,返回:
      • context:上下文向量(编码器输出的加权和),形状 [batch_size, 1, hidden_size]
      • attn_weights:注意力权重(概率分布),形状 [batch_size, 1, src_seq_len]

3. GRU 输入准备

1
input_gru = torch.cat((embedded, context), dim=2)
  • 拼接操作
    将当前词的嵌入向量 embedded 和注意力生成的上下文向量 context 在特征维度(dim=2)拼接
  • 输出形状[batch_size, 1, 2 * hidden_size]
  • 意义
    GRU 同时接收两种信息:
    1. 当前词的语义(embedded
    2. 源序列中与当前步最相关的上下文信息(context

4. GRU 处理和输出预测

1
2
output, hidden = self.gru(input_gru, hidden)
output = self.out(output)
  • GRU 处理
    • 输入:拼接后的 input_gru 和上一时间步的 hidden 状态
    • 输出:
      • output:当前时间步的GRU输出,形状 [batch_size, 1, hidden_size]
      • hidden:更新后的隐藏状态(传递给下一步),形状 [num_layers, batch_size, hidden_size]
  • 线性变换
    self.outoutput 映射到目标词表空间,形状变为 [batch_size, 1, output_size]output_size 是目标词汇表大小)

5. 返回值

1
return output, hidden, attn_weights
  • output:当前步对所有目标词的概率分布(未归一化)
  • hidden:更新后的隐藏状态(用于下一步解码)
  • attn_weights:当前步的注意力权重(可用于可视化对齐关系)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
class BahdanauAttention(nn.Module):
"""Bahdanau注意力机制(加性注意力)"""

def __init__(self, hidden_size):
"""
初始化注意力层
:param hidden_size: 隐藏层维度(必须与编码器/解码器保持一致)
"""
super(BahdanauAttention, self).__init__()
# 三个线性变换层(无偏置)
self.Wa = nn.Linear(hidden_size, hidden_size) # 解码器隐藏状态变换
self.Ua = nn.Linear(hidden_size, hidden_size) # 编码器输出变换
self.Va = nn.Linear(hidden_size, 1) # 计算注意力分数

def forward(self, query, keys):
"""
计算注意力上下文向量
:param query: 解码器当前隐藏状态 [batch_size, 1, hidden_size]
:param keys: 编码器所有输出 [batch_size, seq_len, hidden_size]
:return: (上下文向量, 注意力权重)
"""
# 计算注意力分数
scores = self.Va(torch.tanh(self.Wa(query) + self.Ua(keys))) # [batch_size, seq_len, 1]
scores = scores.squeeze(2).unsqueeze(1) # 调整维度 [batch_size, 1, seq_len]

# 计算注意力权重和上下文向量
weights = F.softmax(scores, dim=-1) # 沿序列维度归一化
context = torch.bmm(weights, keys) # 加权求和 [batch_size, 1, hidden_size]

return context, weights


class AttnDecoderRNN(nn.Module):
"""带注意力机制的RNN解码器"""

def __init__(self, hidden_size, output_size, dropout_p=0.1):
"""
初始化解码器
:param hidden_size: 隐藏层维度
:param output_size: 输出词表大小
:param dropout_p: dropout概率
"""
super(AttnDecoderRNN, self).__init__()
self.embedding = nn.Embedding(output_size, hidden_size) # 词嵌入层
self.attention = BahdanauAttention(hidden_size) # 注意力层
# GRU输入维度为hidden_size*2(因为拼接了嵌入向量和上下文向量)
self.gru = nn.GRU(2 * hidden_size, hidden_size, batch_first=True)
self.out = nn.Linear(hidden_size, output_size) # 输出层
self.dropout = nn.Dropout(dropout_p) # Dropout层

def forward(self, encoder_outputs, encoder_hidden, target_tensor=None):
"""
前向传播(带注意力机制的序列生成)
:param encoder_outputs: 编码器所有输出 [batch_size, seq_len, hidden_size]
:param encoder_hidden: 编码器最终隐藏状态 [num_layers, batch_size, hidden_size]
:param target_tensor: 目标序列(用于Teacher Forcing)
:return: (输出序列概率, 最终隐藏状态, 注意力权重矩阵)
"""
batch_size = encoder_outputs.size(0)
# 初始化解码器输入(<SOS>标记)和隐藏状态
decoder_input = torch.empty(batch_size, 1, dtype=torch.long, device=device).fill_(SOS_token)
decoder_hidden = encoder_hidden
decoder_outputs = [] # 保存所有输出
attentions = [] # 保存所有注意力权重

# 逐步生成序列(最多MAX_LENGTH步)
for i in range(MAX_LENGTH):
decoder_output, decoder_hidden, attn_weights = self.forward_step(
decoder_input, decoder_hidden, encoder_outputs
)
decoder_outputs.append(decoder_output)
attentions.append(attn_weights)

# 决定下一步输入(Teacher Forcing或自回归)
if target_tensor is not None:
decoder_input = target_tensor[:, i].unsqueeze(1) # Teacher Forcing
else:
_, topi = decoder_output.topk(1)
decoder_input = topi.squeeze(-1).detach() # 自回归生成

# 后处理
decoder_outputs = torch.cat(decoder_outputs, dim=1) # 拼接所有输出 [batch_size, seq_len, output_size]
decoder_outputs = F.log_softmax(decoder_outputs, dim=-1) # 计算log概率
attentions = torch.cat(attentions, dim=1) # 拼接注意力权重 [batch_size, tgt_len, src_len]

return decoder_outputs, decoder_hidden, attentions

def forward_step(self, input, hidden, encoder_outputs):
"""单步解码过程"""
embedded = self.dropout(self.embedding(input)) # 词嵌入+dropout [batch_size, 1, hidden_size]

# 计算注意力上下文向量
query = hidden.permute(1, 0, 2) # 调整hidden形状匹配注意力层 [batch_size, 1, hidden_size]
context, attn_weights = self.attention(query, encoder_outputs)

# 拼接嵌入向量和上下文向量作为GRU输入
input_gru = torch.cat((embedded, context), dim=2) # [batch_size, 1, hidden_size*2]

# GRU处理和输出映射
output, hidden = self.gru(input_gru, hidden)
output = self.out(output) # [batch_size, 1, output_size]

return output, hidden, attn_weights

Training

训练时,我们需要将所有输入变为tensor类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
def indexesFromSentence(lang, sentence):
"""将句子中的每个单词转换为对应的索引列表"""
# lang: 包含word2index词典的语言对象
# sentence: 输入的字符串句子
return [lang.word2index[word] for word in sentence.split(' ')]

def tensorFromSentence(lang, sentence):
"""将句子转换为张量形式,并添加EOS结束标记"""
indexes = indexesFromSentence(lang, sentence) # 获取单词索引列表
indexes.append(EOS_token) # 添加句子结束标记
# 转换为PyTorch张量,并调整形状为[1, seq_len]
return torch.tensor(indexes, dtype=torch.long, device=device).view(1, -1)

def tensorsFromPair(pair):
"""将语言对(输入句子,目标句子)转换为输入和目标张量"""
input_tensor = tensorFromSentence(input_lang, pair[0]) # 输入语言张量
target_tensor = tensorFromSentence(output_lang, pair[1]) # 目标语言张量
return (input_tensor, target_tensor)

def get_dataloader(batch_size):
"""创建数据加载器(DataLoader)用于批量训练"""
# 1. 准备数据:读取、过滤并构建词汇表
input_lang, output_lang, pairs = prepareData('eng', 'fra', True)

n = len(pairs)
# 初始化填充后的数组(用0填充短句子)
input_ids = np.zeros((n, MAX_LENGTH), dtype=np.int32)
target_ids = np.zeros((n, MAX_LENGTH), dtype=np.int32)

# 2. 将每个句子对转换为固定长度的数字序列
for idx, (inp, tgt) in enumerate(pairs):
# 处理输入句子
inp_ids = indexesFromSentence(input_lang, inp)
inp_ids.append(EOS_token)
input_ids[idx, :len(inp_ids)] = inp_ids # 前面填充实际内容,后面保持0

# 处理目标句子
tgt_ids = indexesFromSentence(output_lang, tgt)
tgt_ids.append(EOS_token)
target_ids[idx, :len(tgt_ids)] = tgt_ids

# 3. 创建PyTorch数据集和数据加载器
train_data = TensorDataset(
torch.LongTensor(input_ids).to(device), # 输入序列 [n_samples, MAX_LENGTH]
torch.LongTensor(target_ids).to(device) # 目标序列 [n_samples, MAX_LENGTH]
)

train_sampler = RandomSampler(train_data) # 随机采样器
train_dataloader = DataLoader(
train_data,
sampler=train_sampler,
batch_size=batch_size # 按指定批量大小加载数据
)
return input_lang, output_lang, train_dataloader

Training the Model

为了训练,我们通过编码器运行输入句子,并跟踪每个输出和最新的隐藏状态。然后,解码器被赋予<SOS>令牌作为其第一个输入,编码器的最后一个隐藏状态作为其第一个隐藏状态。

“Teacher Forcing”是指使用实际目标输出作为下一个输入,而不是使用解码器的猜测作为下一个输入的概念。使用Teacher Forcing会导致它收敛得更快,但当训练有素的网络被利用时,它可能会表现出不稳定。

你可以观察Teacher Forcing网络的输出,这些网络用连贯的语法阅读,但远离正确的翻译——直观地,它已经学会了表示输出语法,一旦teacher告诉它前几个单词,就可以理解意思,但它一开始就没有正确学会如何从翻译中创建句子。

这部分内容和之前的差不多,因此不再赘述。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
def train_epoch(dataloader, encoder, decoder, encoder_optimizer,
decoder_optimizer, criterion):

total_loss = 0
for data in dataloader:
input_tensor, target_tensor = data

encoder_optimizer.zero_grad()
decoder_optimizer.zero_grad()

encoder_outputs, encoder_hidden = encoder(input_tensor)
decoder_outputs, _, _ = decoder(encoder_outputs, encoder_hidden, target_tensor)

loss = criterion(
decoder_outputs.view(-1, decoder_outputs.size(-1)),
target_tensor.view(-1)
)
loss.backward()

encoder_optimizer.step()
decoder_optimizer.step()

total_loss += loss.item()

return total_loss / len(dataloader)

我们添加一个运行时间的函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import time
import math

def asMinutes(s):
m = math.floor(s / 60)
s -= m * 60
return '%dm %ds' % (m, s)

def timeSince(since, percent):
now = time.time()
s = now - since
es = s / (percent)
rs = es - s
return '%s (- %s)' % (asMinutes(s), asMinutes(rs))

整个流程的训练代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
def train(train_dataloader, encoder, decoder, n_epochs, learning_rate=0.001,
print_every=100, plot_every=100):
start = time.time()
plot_losses = []
print_loss_total = 0 # Reset every print_every
plot_loss_total = 0 # Reset every plot_every

encoder_optimizer = optim.Adam(encoder.parameters(), lr=learning_rate)
decoder_optimizer = optim.Adam(decoder.parameters(), lr=learning_rate)
criterion = nn.NLLLoss()

for epoch in range(1, n_epochs + 1):
loss = train_epoch(train_dataloader, encoder, decoder, encoder_optimizer, decoder_optimizer, criterion)
print_loss_total += loss
plot_loss_total += loss

if epoch % print_every == 0:
print_loss_avg = print_loss_total / print_every
print_loss_total = 0
print('%s (%d %d%%) %.4f' % (timeSince(start, epoch / n_epochs),
epoch, epoch / n_epochs * 100, print_loss_avg))

if epoch % plot_every == 0:
plot_loss_avg = plot_loss_total / plot_every
plot_losses.append(plot_loss_avg)
plot_loss_total = 0

showPlot(plot_losses)

结果绘制:

1
2
3
4
5
6
7
8
9
10
11
12
import matplotlib.pyplot as plt
plt.switch_backend('agg')
import matplotlib.ticker as ticker
import numpy as np

def showPlot(points):
plt.figure()
fig, ax = plt.subplots()
# this locator puts ticks at regular intervals
loc = ticker.MultipleLocator(base=0.2)
ax.yaxis.set_major_locator(loc)
plt.plot(points)

Evaluation

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
def evaluate(encoder, decoder, sentence, input_lang, output_lang):
with torch.no_grad():
input_tensor = tensorFromSentence(input_lang, sentence)

encoder_outputs, encoder_hidden = encoder(input_tensor)
decoder_outputs, decoder_hidden, decoder_attn = decoder(encoder_outputs, encoder_hidden)

_, topi = decoder_outputs.topk(1)
decoded_ids = topi.squeeze()

decoded_words = []
for idx in decoded_ids:
if idx.item() == EOS_token:
decoded_words.append('<EOS>')
break
decoded_words.append(output_lang.index2word[idx.item()])
return decoded_words, decoder_attn

def evaluateRandomly(encoder, decoder, n=10):
for i in range(n):
pair = random.choice(pairs)
print('>', pair[0])
print('=', pair[1])
output_words, _ = evaluate(encoder, decoder, pair[0], input_lang, output_lang)
output_sentence = ' '.join(output_words)
print('<', output_sentence)
print('')

End: Training and Evaluating

1
2
3
4
5
6
7
8
9
hidden_size = 128
batch_size = 32

input_lang, output_lang, train_dataloader = get_dataloader(batch_size)

encoder = EncoderRNN(input_lang.n_words, hidden_size).to(device)
decoder = AttnDecoderRNN(hidden_size, output_lang.n_words).to(device)

train(train_dataloader, encoder, decoder, 80, print_every=5, plot_every=5)

1
2
3
encoder.eval()
decoder.eval()
evaluateRandomly(encoder,decoder)

Visualizing Attention

我们通过一个热力图来使得注意力机制可视化

1. showAttention(input_sentence, output_words, attentions)

这个函数用于绘制注意力权重图。

  • 参数:
    • input_sentence: 输入的源语言句子(法语)
    • output_words: 模型输出的目标语言单词(英语)
    • attentions: 注意力权重矩阵
  • 功能:
    1. 创建一个热力图显示注意力权重
    2. 设置x轴为输入句子的单词(加上<EOS>结束符)
    3. 设置y轴为输出单词
    4. 确保每个刻度都显示标签
    5. 显示图形

2. evaluateAndShowAttention(input_sentence)

这个函数结合了评估和可视化功能。

  • 参数:
    • input_sentence: 要翻译的法语句子
  • 功能:
    1. 调用evaluate函数获取翻译结果和注意力权重
    2. 打印输入和输出句子
    3. 调用showAttention显示注意力权重图
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
def showAttention(input_sentence, output_words, attentions):
fig = plt.figure()
ax = fig.add_subplot(111)
cax = ax.matshow(attentions.cpu().numpy(), cmap='bone')
fig.colorbar(cax)

# 设置x轴
x_labels = [''] + input_sentence.split(' ') + ['<EOS>']
ax.set_xticks(range(len(x_labels))) # 先设置固定数量的刻度
ax.set_xticklabels(x_labels, rotation=90)

# 设置y轴
y_labels = [''] + output_words
ax.set_yticks(range(len(y_labels))) # 先设置固定数量的刻度
ax.set_yticklabels(y_labels)

plt.show()

def evaluateAndShowAttention(input_sentence):
output_words, attentions = evaluate(encoder, decoder, input_sentence,input_lang, output_lang)
print('input = ', input_sentence)
print('output = ',' '.join(output_words))
showAttention(input_sentence,output_words,attentions[0,:len(output_words),:])

evaluateAndShowAttention('il n est pas aussi grand que son pere')

evaluateAndShowAttention('je suis trop fatigue pour conduire')

evaluateAndShowAttention('je suis desole si c est une question idiote')

evaluateAndShowAttention('je suis reellement fiere de vous')


More

Related Paper

  1. Learning Phrase Representations using RNN Encoder-Decoder for Statistical Machine Translation
  2. Sequence to Sequence Learning with Neural Networks
  3. Neural Machine Translation by Jointly Learning to Align and Translate
  4. A Neural Conversational Model
  5. Effective Approaches to Attention-based Neural Machine Translation.