- ???? 本文为????365天深度学习训练营 中的学习记录博客
- ???? 原作者:K同学啊
理论基础
seq2seq(Sequence-to-Sequence)模型是一种用于机器翻译、文本摘要等序列转换任务的框架。它由两个主要的递归神经网络(RNN)组成:一个编码器(Encoder)和一个解码器(Decoder)。下面是seq2seq模型实现翻译的基本原理:
-
编码器(Encoder):
- 输入:编码器接收一个源语言句子,这个句子已经被分割成一系列的单词或字符,通常表示为( x_1, x_2, …, x_T )。
- 处理:编码器逐个处理这些输入,并为每个输入生成一个隐藏状态( h_t )。在这个过程中,编码器会构建一个代表整个输入句子的内部表示(context vector)。
- 输出:最后,编码器输出一个固定大小的上下文向量( c ),这个向量包含了输入句子的语义信息。
-
上下文向量(Context Vector):
- 上下文向量是编码器输出的一个汇总,它捕获了整个输入句子的信息。这个向量通常是通过编码器最后一个隐藏状态或者对所有隐藏状态进行池化得到的。
-
解码器(Decoder):
- 输入:解码器接收上下文向量( c )和之前生成的目标语言句子的一部分作为输入,通常表示为( y_1, y_2, …, y_{T’} )。
- 处理:解码器基于当前的目标语言句子部分和上下文向量来生成下一个单词的概率分布。在每一步,解码器都会更新其隐藏状态,并使用它来预测下一个单词。
- 输出:解码器输出一个概率分布,表示在给定当前输入的情况下,下一个目标语言单词的所有可能性的概率。
-
训练过程:
- 在训练过程中,seq2seq模型使用最大似然估计来优化模型的参数。这意味着模型试图最大化目标句子在给定源句子的条件下的概率。
- 通常,解码器在训练时会使用教师强制(Teacher Forcing)策略,即在每一步都提供真实的下一个目标单词作为输入,而不是使用上一步的预测结果。
-
推理过程:
- 在推理(或测试)时,模型的解码器通常会使用自己上一步的输出作为下一步的输入,直到生成一个结束标记或达到最大输出长度。
seq2seq模型的关键优势在于它的灵活性:它可以处理任意长度的输入和输出序列。此外,由于编码器和解码器都是RNN,它们能够捕捉到序列中的长距离依赖关系。
在实际应用中,基础的seq2seq模型可能会遇到一些问题,比如难以处理长序列和缺乏对输入序列的注意力机制。因此,研究者们提出了许多改进版本,如使用长短时记忆网络(LSTM)或门控循环单元(GRU)来替代基本的RNN,以及引入注意力机制(Attention Mechanism)来允许解码器关注输入序列的不同部分。这些改进显著提高了seq2seq模型在机器翻译等任务上的性能。
- 在推理(或测试)时,模型的解码器通常会使用自己上一步的输出作为下一步的输入,直到生成一个结束标记或达到最大输出长度。
一、环境准备(导入基本的包以供使用)
-
__future__
: 这个模块允许你使用未来版本的Python特性。在这个例子中,它启用了Python 2的print_function
(使得print
成为一个函数,而不是一个语句),unicode_literals
(使得所有的字符串默认为Unicode),和division
(改变了除法的运算规则,在Python 2中,整数相除会得到整数结果,而不是浮点数)。 -
io.open
: 这个模块提供了一个统一的接口来打开文件。导入open
函数是为了确保在Python 2和Python 3中打开文件的方式是一致的。 -
unicodedata
: 这个模块提供了对Unicode字符数据库的访问,可以用于检查和处理Unicode字符。 -
string
: 这个模块包含了常用的字符串操作。在这个脚本中,它可能被用来处理字符集或字符串常量。 -
re
: 这是Python的正则表达式模块,用于字符串的搜索和替换操作。 -
random
: 这个模块提供了生成随机数的工具。 -
torch
: 这是PyTorch框架的主要模块,用于构建和训练神经网络。 -
torch.nn
: 这是PyTorch的神经网络模块,提供了创建和训练神经网络所需的所有工具。 -
torch.optim
: 这个模块包含了各种优化算法,用于在训练过程中调整神经网络的权重。 -
torch.nn.functional
: 这个模块提供了神经网络中使用的激活函数和其他功能性函数。
最后,代码检查了CUDA(一种用于GPU加速计算的框架)是否可用,如果可用,则将PyTorch的设备设置为CUDA,否则使用CPU。这决定了神经网络模型将在哪个设备上运行。
from __future__ import unicode_literals, print_function, division
from io import open
import unicodedata
import string
import re
import random
import torch
import torch.nn as nn
from torch import optim
import torch.nn.functional as F
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(device)
输出
cuda
二、前期的语料处理
1.搭建语言类
这段代码定义了一个名为Lang
的类,它用于处理语言相关的数据,例如构建词汇表、将单词映射到索引等。这个类在处理自然语言数据时非常有用,特别是在构建神经机器翻译系统时。
SOS_token = 0
EOS_token = 1
这两行代码定义了两个特殊标记的整数值,SOS_token
代表“开始符”(Start of Sentence),EOS_token
代表“结束符”(End of Sentence)。这些标记用于在序列的开头和结尾处标识句子的开始和结束。
class Lang:
def __init__(self, name):
self.name = name
self.word2index = {}
self.word2count = {}
self.index2word = {0: "SOS", 1: "EOS"}
self.n_words = 2 # Count SOS and EOS
这段代码定义了Lang
类的构造函数。它接受一个参数name
,表示语言的名称。然后初始化了几个重要的属性:
-
self.name
:存储语言的名称。 -
self.word2index
:一个字典,用于将单词映射到它们在词汇表中的索引。 -
self.word2count
:一个字典,用于记录每个单词在语料库中出现的次数。 -
self.index2word
:一个字典,用于将索引映射回单词。初始时,它包含两个特殊标记SOS
和EOS
。 -
self.n_words
:一个整数,表示词汇表中的单词数量,初始值为2(因为已经包含了SOS
和EOS
)。
def addSentence(self, sentence):
for word in sentence.split(' '):
self.addWord(word)
这个方法addSentence
接受一个句子作为输入,并将其中的每个单词添加到词汇表中。它通过调用addWord
方法来实现这一点,该方法将在下一行中定义。
def addWord(self, word):
if word not in self.word2index:
self.word2index[word] = self.n_words
self.word2count[word] = 1
self.index2word[self.n_words] = word
self.n_words += 1
else:
self.word2count[word] += 1
addWord
方法用于将单个单词添加到词汇表中。如果单词不在词汇表中,它会将单词添加到word2index
字典中,并为其分配一个新的索引(self.n_words
),然后在index2word
字典中记录这个索引到单词的映射,并更新n_words
计数。如果单词已经在词汇表中,它会更新word2count
字典中该单词的计数。
总的来说,Lang
类提供了一个方便的方式来构建和处理与特定语言相关的词汇表,这在序列到序列的学习任务(如机器翻译)中是非常重要的。
2.文本处理函数
这段代码包含两个函数,unicodeToAscii
和normalizeString
,用于处理和规范化文本数据。
def unicodeToAscii(s):
return ''.join(
c for c in unicodedata.normalize('NFD', s)
if unicodedata.category(c) != 'Mn'
)
这个函数unicodeToAscii
接受一个字符串s
作为输入,并返回一个仅包含ASCII字符的字符串。它首先使用unicodedata.normalize('NFD', s)
将输入字符串分解为组合字符序列。然后,它遍历每个字符,检查其类别是否为’Mn’(非间距标记),如果是,则忽略该字符(不加入到最终的字符串中)。否则,将该字符加入到最终的字符串中。这样做是为了去除字符串中的变音符号,如重音字符。
在讨论字符编码和文本处理时,“Mn”(非间距标记)是Unicode字符类别之一,用于表示非间距标记字符。这类字符通常用于与其他字符结合,以形成特定的文字或音标,它们不会占据额外的空间,而是放在基础字符的上方、下方或穿过基础字符。
例如,在法语中,字母“e”上面可能有一个非间距的重音标记(例如,é),这个重音标记就是一个非间距标记字符。在Unicode中,这个重音标记和“e”是分开的字符,但当你将它们放在一起时,它们会显示为一个带有重音的字符。
在文本处理中,有时需要将这些非间距标记与它们的基础字符分开处理,例如,当需要将文本转换为纯ASCII形式时,可能需要去除这些非间距标记。这就是unicodeToAscii
函数的目的,它通过移除非间距标记,将文本转换为只包含ASCII字符的形式。
在Python的
unicodedata
模块中,normalize('NFD', s)
函数调用是将字符串s
进行Unicode正规化(Normalization)的一种形式。NFD
是Normalization Form D的缩写,代表“Normalization Form Canonical Decomposition”。这种正规化形式将每个Unicode字符分解为其组成部分的基本字符(即组合字符序列)。
具体来说,NFD
执行以下操作:
- 分解(Decomposition):它将所有字符分解为它们的组合部分。例如,一个带重音的字符(如é)会被分解为基本字符(e)和一个非间距标记(´)。
- 规范(Canonical):它确保分解是规范化的,即遵循Unicode标准中定义的官方分解规则。 使用
NFD
形式的好处是,它可以使得不同的字符表示方式标准化,这样就可以更容易地进行比较和排序。在处理文本数据时,这有助于确保相同的语义内容得到一致的编码。
在unicodeToAscii
函数中,使用NFD
正规化形式是为了能够识别并去除字符串中的非间距标记(Mn类别的字符),从而将字符转换为它们的ASCII等效形式。这是因为在分解后,非间距标记会被独立出来,从而可以轻松地被过滤掉。
# 小写化,剔除标点与非字母符号
def normalizeString(s):
s = unicodeToAscii(s.lower().strip()) #s.lower().strip()将字符串转换为小写,并去除首尾的空白字符。
s = re.sub(r"([.!?])", r" \1", s)
s = re.sub(r"[^a-zA-Z.!?]+", r" ", s)
return s
这个函数normalizeString
接受一个字符串s
作为输入,并返回一个规范化后的字符串。它首先调用unicodeToAscii
函数将输入字符串转换为仅包含ASCII字符的形式,并将其转换为小写,并去除首尾的空白字符。然后,它使用正则表达式re.sub
来处理字符串中的标点符号和非字母字符:
-
re.sub(r"([.!?])", r" \1", s)
:这个表达式在句号、问号和感叹号前面添加一个空格,这样这些标点符号就会被单独视为一个词。
如果有看不懂这句代码的语法的,这里是详细解释:
这行代码使用Python的
re.sub
函数来替换字符串中的特定字符。
re.sub
:这是Python中re
模块的一个函数,用于在字符串中查找和替换模式。r
:这是一个前缀,表示字符串是原始字符串(raw string),这意味着反斜杠\
不会被当作特殊字符处理,而是按照字面意义进行匹配。"([.!?])"
:这是第一个参数,是一个正则表达式模式:
[.!?]
:方括号表示一个字符集,匹配方括号内的任意一个字符,这里表示句号、问号或感叹号。r" \1"
:这是第二个参数,是替换字符串:
:这是一个空格字符,表示要在匹配的字符前添加一个空格。
\1
:这是一个反向引用(backreference),它引用第一个捕获组匹配的文本(即句号、问号或感叹号)。s
:这是第三个参数,是要进行替换操作的原始字符串。
-
re.sub(r"[^a-zA-Z.!?]+", r" ", s)
:这个表达式将所有非字母字符(除了句号、问号和感叹号)替换为单个空格。这样做的目的是去除字符串中的其他标点符号和特殊字符,只保留字母、句号、问号和感叹号。
总的来说,normalizeString
函数的目的是将输入的字符串转换为一种标准格式,以便于后续的处理和分析。
3、文件读取函数
def readLangs(lang1, lang2, reverse=False):#reverse这个选项的作用,举个例子,就可以很好理解,当训练一个从法语到英语的翻译模型时,有时候需要将数据集中的句子对反转,以便模型学习如何从英语翻译到法语。
print("Reading lines...")
# 以行为单位读取文件
lines = open('eng-fra.txt'.format(lang1,lang2), encoding='utf-8').read().strip().split('\n')
# 将每一行放入一个列表中
# 一个列表中有两个元素,A语言文本与B语言文本
pairs = [[normalizeString(s) for s in l.split('\t')] for l in lines]
# 创建Lang实例,并确认是否反转语言顺序
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
这段代码定义了一个名为readLangs
的函数,用于读取和预处理一对语言的文本数据。
def readLangs(lang1, lang2, reverse=False):
定义函数readLangs
,它接受三个参数:lang1
和lang2
是两种语言的名称,reverse
是一个布尔值,用于指示是否需要反转语言对的顺序。
print("Reading lines...")
打印一条消息,表示开始读取文件。
# 以行为单位读取文件
lines = open('eng-fra.txt'.format(lang1,lang2), encoding='utf-8').read().strip().split('\n')
这行代码读取一个文本文件,该文件包含两种语言的句子对。文件名由lang1
和lang2
参数格式化而成,例如eng-fra.txt
。文件以UTF-8编码读取,然后去除首尾空白字符,并根据换行符分割成行列表。
# 将每一行放入一个列表中
# 一个列表中有两个元素,A语言文本与B语言文本
pairs = [[normalizeString(s) for s in l.split('\t')] for l in lines]
这行代码使用列表推导式来处理每一行。每行通过制表符\t
分割成两个元素,分别代表两种语言的句子。然后,normalizeString
函数被应用于每个句子,以进行规范化处理。处理后的句子对被放入一个列表pairs
中。
# 创建Lang实例,并确认是否反转语言顺序
if reverse:
pairs = [list(reversed(p)) for p in pairs]
input_lang = Lang(lang2)
output_lang = Lang(lang1)
如果reverse
参数为True
,则反转pairs
中的每个句子对,并创建Lang
实例input_lang
和output_lang
,其中input_lang
是第二种语言,output_lang
是第一种语言。
else:
input_lang = Lang(lang1)
output_lang = Lang(lang2)
如果reverse
参数为False
,则保持pairs
中的句子对顺序不变,并创建Lang
实例input_lang
和output_lang
,其中input_lang
是第一种语言,output_lang
是第二种语言。
return input_lang, output_lang, pairs
函数返回三个值:input_lang
(输入语言)、output_lang
(输出语言)和pairs
(处理后的句子对列表)。
这里有一个小点,教案给的示例这一句
lines = open(‘eng-fra.txt’.format(lang1,lang2), encoding=‘utf-8’).read().strip().split(‘\n’)
这里面.format在教案中是%
但是会出现这样的报错
这个错误信息表明在尝试使用字符串格式化时出现了问题。具体来说,错误发生在这一行代码中:
lines = open('./end-fra.txt'%(lang1,lang2), encoding='utf-8').read().strip().split('\n')
错误的原因是字符串格式化方法使用不当。在这里,'%(lang1,lang2)'
这样的写法是错误的,因为它试图将两个变量 lang1
和 lang2
作为元组进行格式化,而 %
格式化方法不能直接应用于元组。
正确的做法应该是使用 %
格式化方法,将 lang1
和 lang2
作为单独的参数传递,或者使用 .format()
方法,或者如果使用的是Python 3.6以上的版本,可以使用f-strings。以下是使用 .format()
方法的示例:
lines = open('./end-{}-{}.txt'.format(lang1, lang2), encoding='utf-8').read().strip().split('\n')
或者,使用f-strings:
lines = open(f'./end-{lang1}-{lang2}.txt', encoding='utf-8').read().strip().split('\n')
这样,lang1
和 lang2
的值就会被正确地插入到文件名中,从而避免了 TypeError
。
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 "
)
def filterPair(p):
return len(p[0].split(' ')) < MAX_LENGTH and \
len(p[1].split(' ')) < MAX_LENGTH and p[1].startswith(eng_prefixes)
def filterPairs(pairs):
# 选取仅仅包含 eng_prefixes 开头的语料
return [pair for pair in pairs if filterPair(pair)]
这段代码定义了一些常量,并提供了两个函数,用于过滤一对语言的句子对。
eng_prefixes = (
"i am ", "i m ",
"he is", "he s ",
# ... 其他英文前缀
)
这行代码定义了一个名为eng_prefixes
的列表,其中包含了一些英文句子的前缀。这些前缀在英语中很常见,可能出现在翻译任务的数据集中。列表中的每个元素都是一个前缀,后面跟着一个空格。
def filterPair(p):
return len(p[0].split(' ')) < MAX_LENGTH and \
len(p[1].split(' ')) < MAX_LENGTH and p[1].startswith(eng_prefixes)
这行代码定义了一个名为filterPair
的函数,它接受一个参数p
,代表一对句子。这个函数检查以下条件:
-
len(p[0].split(' ')) < MAX_LENGTH
:确保第一个句子(源语言句子)的单词数量不超过MAX_LENGTH
。 -
len(p[1].split(' ')) < MAX_LENGTH
:确保第二个句子(目标语言句子)的单词数量不超过MAX_LENGTH
。 -
p[1].startswith(eng_prefixes)
:确保第二个句子以eng_prefixes
列表中的某个前缀开头。
如果所有这些条件都满足,函数返回True
,表示这对句子应该被保留;否则,返回False
。
def filterPairs(pairs):
# 选取仅仅包含 eng_prefixes 开头的语料
return [pair for pair in pairs if filterPair(pair)]
这行代码定义了一个名为filterPairs
的函数,它接受一个参数pairs
,代表一个包含句子对的列表。这个函数使用列表推导式遍历pairs
中的每个句子对,并使用filterPair
函数检查每个句子对是否满足过滤条件。如果满足条件,句子对会被包含在新的列表中,最终返回这个新列表。
综上所述,这两个函数用于过滤句子对,确保它们满足特定的长度和前缀条件。
def prepareData(lang1, lang2, reverse=False):
# 读取文件中的数据
input_lang, output_lang, pairs = readLangs(lang1, lang2, reverse)
print("Read %s sentence pairs" % len(pairs))
# 按条件选取语料
pairs = filterPairs(pairs[:])
print("Trimmed to %s sentence pairs" % len(pairs))
print("Counting words...")
# 将语料保存至相应的语言类
for pair in pairs:
input_lang.addSentence(pair[0])
output_lang.addSentence(pair[1])
# 打印语言类的信息
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))
这段代码定义了一个名为prepareData
的函数,用于准备和处理一对语言的句子对数据。
def prepareData(lang1, lang2, reverse=False):
定义函数prepareData
,它接受三个参数:lang1
和lang2
是两种语言的名称,reverse
是一个布尔值,用于指示是否需要反转语言对的顺序。
# 读取文件中的数据
input_lang, output_lang, pairs = readLangs(lang1, lang2, reverse)
调用readLangs
函数,它从文件中读取数据并返回输入语言、输出语言和句子对列表。
print("Read %s sentence pairs" % len(pairs))
打印一条消息,表示已经读取了指定数量的句子对。
# 按条件选取语料
pairs = filterPairs(pairs[:])
使用filterPairs
函数对句子对进行过滤,确保它们满足特定的条件。pairs[:]
创建了pairs
列表的副本,这样原始的pairs
列表不会被修改。
print("Trimmed to %s sentence pairs" % len(pairs))
打印一条消息,表示过滤后剩下的句子对数量。
print("Counting words...")
打印一条消息,表示开始计数单词。
# 将语料保存至相应的语言类
for pair in pairs:
input_lang.addSentence(pair[0])
output_lang.addSentence(pair[1])
遍历过滤后的句子对列表,将每个句子添加到相应的语言类中。
# 打印语言类的信息
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)
调用prepareData
函数,传入参数'eng'
和'fra'
,并设置reverse
为True
,表示需要反转句子对。
print(random.choice(pairs))
打印一个随机选择的句子对,用于验证数据处理是否正确。
综上所述,prepareData
函数读取数据,过滤句子对,将句子添加到语言类中,并返回处理后的输入语言、输出语言和句子对列表。
输出
Reading lines…
Read 135842 sentence pairs
Trimmed to 10599 sentence pairs
Counting words…
Counted words:
fra 4345
eng 2803
[‘vous gaspillez mon temps .’, ‘you re wasting my time .’]
三、seq2seq模型
1.编码器
class EncoderRNN(nn.Module):
def __init__(self, input_size, hidden_size):
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)
def forward(self, input, hidden):
embedded = self.embedding(input).view(1, 1, -1)
output = embedded
output, hidden = self.gru(output, hidden)
return output, hidden
def initHidden(self):
return torch.zeros(1, 1, self.hidden_size, device=device)
这段代码定义了一个名为EncoderRNN
的类,它是PyTorch中的一个神经网络模块,用于实现序列到序列(seq2seq)模型中的编码器部分。
class EncoderRNN(nn.Module):
def __init__(self, input_size, hidden_size):
这行定义了EncoderRNN
类的构造函数。它接受两个参数:input_size
和hidden_size
。input_size
是输入序列中单词的数量,通常对应于词汇表的大小。hidden_size
是GRU单元的隐藏状态的大小,它决定了模型能够学习到的复杂度。
super(EncoderRNN, self).__init__()
这行代码调用父类nn.Module
的构造函数。nn.Module
是PyTorch中所有神经网络模块的基类,它提供了神经网络的基础功能,如参数管理、前向传播和反向传播。通过调用super(EncoderRNN, self).__init__()
,EncoderRNN
类继承了nn.Module
类的所有功能。
self.hidden_size = hidden_size
这行代码将hidden_size
参数设置为EncoderRNN
类的属性。这个属性将在后续的代码中用于访问和修改隐藏状态的大小。
self.embedding = nn.Embedding(input_size, hidden_size)
这行代码创建了一个嵌入层(self.embedding
)。嵌入层是一个线性层,它将输入的整数索引(代表单词)转换为固定大小的向量。在这里,嵌入层的输入大小是input_size
,输出大小是hidden_size
。
self.gru = nn.GRU(hidden_size, hidden_size)
这行代码创建了一个GRU(门控循环单元)层(self.gru
)。GRU是一种RNN(循环神经网络)的变体,它将传统的RNN的三个门(输入门、遗忘门和输出门)合并为两个门(更新门和重置门)。在这里,GRU的输入大小和隐藏大小都是hidden_size
。
综上所述,EncoderRNN
类的构造函数__init__
负责初始化类实例的属性,包括设置隐藏状态的大小、创建嵌入层和GRU层。这些步骤是构建一个序列到序列模型中编码器组件的基础。
在用户引用的对话内容中,我们看到了EncoderRNN
类的forward
方法。这个方法定义了前向传播的逻辑,即神经网络在输入数据上的计算过程。下面是详细解释:
def forward(self, input, hidden):
这行定义了EncoderRNN
类的forward
方法。它接受两个参数:input
和hidden
。input
是当前时间步的输入序列,通常是单词的索引。hidden
是GRU单元的隐藏状态,它是从前一个时间步传递过来的,用于初始化当前时间步的隐藏状态。
embedded = self.embedding(input).view