DL notes 03:机器翻译(MT)/NLP 基础知识
@[toc]
一、机器翻译
机器翻译(MT)
:将一段文本从一种语言自动翻译为另一种语言,用神经网络解决这个问题通常称为神经机器翻译(NMT)。 主要特征:输出是单词序列而不是单个单词。 输出序列的长度可能与源序列的长度不同。
1.1 数据预处理和清洗
将数据集清洗、转化为神经网络的输入minbatch,这是任何一个神经网络应用的首要步骤。
字符在计算机里是以编码的形式存在,我们通常所用的空格是 \x20
,是在标准ASCII可见字符 0x20~0x7e
范围内。 而 \xa0
属于 latin1 (ISO/IEC_8859-1)中的扩展字符集字符,代表不间断空白符nbsp
(non-breaking space),超出gbk编码范围,是需要去除的特殊字符。再数据预处理的过程中,我们首先需要对数据进行清洗。
def preprocess_raw(text):
text = text.replace('\u202f', ' ').replace('\xa0', ' ')
out = ''
for i, char in enumerate(text.lower()):
if char in (',', '!', '.') and i > 0 and text[i-1] != ' ':
out += ' '
out += char
return out
1.2 文本预处理
1.2.1 分词
字符串—单词组成的列表,属于文本预处理环节的一步。我们对每个句子进行分词,也就是将一个句子划分成若干个词(token),转换为一个词的序列。同样已经有很多工具可以直接用于分词。 用现有工具进行分词 我们前面介绍的分词方式非常简单,它至少有以下几个缺点: 1. 标点符号通常可以提供语义信息,但是我们的方法直接将其丢弃了 2. 类似“shouldn’t”, “doesn’t”这样的词会被错误地处理 3. 类似”Mr.“, “Dr.“这样的词会被错误地处理
我们可以通过引入更复杂的规则来解决这些问题,但是事实上,有一些现有的工具可以很好地进行分词,我们在这里简单介绍其中的两个:spaCy和NLTK。 举例如下:
# spaCy
import spacy
nlp = spacy.load('en_core_web_sm')
doc = nlp(text)
print([token.text for token in doc])
# NLTK
from nltk.tokenize import word_tokenize
from nltk import data
data.path.append('/home/kesci/input/nltk_data3784/nltk_data')
print(word_tokenize(text))
1.2.2 建立字典
为了方便模型处理,我们需要将字符串转换为数字。因此我们需要先构建一个字典(vocabulary),将每个词映射到一个唯一的索引编号。建立字典的过程会得到如下信息: 1. 去重后词典,及其中单词对应的索引列表 2. 还可以得到给定索引找到其对应的单词的列表,以及给定单词得到对应索引的字典。 3. 原始语料所有词对应的词典索引的列表
注意:在构建字典时我们要对每个句子的开头,结尾,以及未知词汇特设元素对应。另外我们数据集中的句子并非对齐的,我们也要设置padding
特殊词来对短句子进行补全长度。同样对于过长的句子我们也要进行截断操作,节省计算开支。
class Vocab(object):
def __init__(self, tokens, min_freq=0, use_special_tokens=False):
counter = count_corpus(tokens) # :
self.token_freqs = list(counter.items())
self.idx_to_token = []
if use_special_tokens:
# padding, begin of sentence, end of sentence, unknown
self.pad, self.bos, self.eos, self.unk = (0, 1, 2, 3)
self.idx_to_token += ['<pad>', '<bos>', '<eos>', '<unk>']
else:
self.unk = 0
self.idx_to_token += ['<unk>']
self.idx_to_token += [token for token, freq in self.token_freqs
if freq >= min_freq and token not in self.idx_to_token]
self.token_to_idx = dict()
for idx, token in enumerate(self.idx_to_token):
self.token_to_idx[token] = idx
def __len__(self):
return len(self.idx_to_token)
def __getitem__(self, tokens):
if not isinstance(tokens, (list, tuple)):
return self.token_to_idx.get(tokens, self.unk)
return [self.__getitem__(token) for token in tokens]
def to_tokens(self, indices):
if not isinstance(indices, (list, tuple)):
return self.idx_to_token[indices]
return [self.idx_to_token[index] for index in indices]
def count_corpus(sentences):
tokens = [tk for st in sentences for tk in st]
return collections.Counter(tokens) # 返回一个字典,记录每个词的出现次数
在生成字典时,我们会通常把高频出现的单词放在字典开始的部分,这样可以减少查询次数(相对于随机编码),训练word2vec中有个HUffman树,也是这个思想。
token_freqs = sorted(counter.items(), key=lambda x:x[0])
token_freqs.sort(key=lambda x:x[1], reverse=True)
1.2.3 将词转为索引
使用字典,我们可以将原文本中的句子从单词序列转换为索引序列
1.3 语言模型
一段自然语言文本可以看作是一个离散时间序列,给定一个长度$T$为的词的序列$w_1, w_2, \ldots, w_T$,语言模型的目标就是评估该序列是否合理,即计算该序列的概率:
$$P(w_1, w_2, \ldots, w_T).$$
本节我们介绍基于统计的语言模型,主要是$n$元语法($n$-gram)。在后续内容中,我们将会介绍基于神经网络的语言模型。
假设序列$w_1, w_2, \ldots, w_T$中的每个词是依次生成的,我们有
$$
\begin{aligned}
P(w_1, w_2, \ldots, wT)
&= \prod{t=1}^T P(w_t \mid w1, \ldots, w{t-1})
&= P(w_1)P(w_2 \mid w_1) \cdots P(w_T \mid w_1w2\cdots w{T-1})
\end{aligned}
$$
例如,一段含有4个词的文本序列的概率
$$P(w_1, w_2, w_3, w_4) = P(w_1) P(w_2 \mid w_1) P(w_3 \mid w_1, w_2) P(w_4 \mid w_1, w_2, w_3).$$
语言模型的参数就是词的概率以及给定前几个词情况下的条件概率。设训练数据集为一个大型文本语料库,如维基百科的所有条目,词的概率可以通过该词在训练数据集中的相对词频来计算,例如,$w_1$的概率可以计算为:
$$\hat P(w_1) = \frac{n(w_1)}{n}$$
其中$n(w_1)$为语料库中以作为第一个词的文本的数量,$n$为语料库中文本的总数量。
类似的,给定$w_1$情况下,$w_2$的条件概率可以计算为: $$\hat P(w_2 \mid w_1) = \frac{n(w_1, w_2)}{n(w_1)}$$ 其中$n(w_1, w_2)$为语料库中以$w_1$作为第一个词,$w_2$作为第二个词的文本的数量。
$n$元语法
序列长度增加,计算和存储多个词共同出现的概率的复杂度会呈指数级增加。$n$元语法通过马尔可夫假设简化模型,马尔科夫假设是指一个词的出现只与前面$n$个词相关,即$n$阶马尔可夫链(Markov chain of order $n$),如果$n=1$,那么有$P(w_3 \mid w_1, w_2) = P(w_3 \mid w_2)$。基于$n-1$阶马尔可夫链,我们可以将语言模型改写为
$$P(w_1, w_2, \ldots, wT) = \prod{t=1}^T P(wt \mid w{t-(n-1)}, \ldots, w_{t-1}) . $$
以上也叫$n$元语法($n$-grams),它是基于$n-1$阶马尔可夫链的概率语言模型。例如,当时$n=2$,含有4个词的文本序列的概率就可以改写为:
$$
\begin{aligned}
P(w_1, w_2, w_3, w_4)
&= P(w_1) P(w_2 \mid w_1) P(w_3 \mid w_1, w_2) P(w_4 \mid w_1, w_2, w_3)
&= P(w_1) P(w_2 \mid w_1) P(w_3 \mid w_2) P(w_4 \mid w_3)
\end{aligned}
$$
当$n$分别为1、2和3时,我们将其分别称作一元语法(unigram)、二元语法(bigram)和三元语法(trigram)。例如,长度为4的序列$w_1, w_2, w_3, w_4$在一元语法、二元语法和三元语法中的概率分别为
$$
\begin{aligned}
P(w_1, w_2, w_3, w_4) &= P(w_1) P(w_2) P(w_3) P(w_4) ,
P(w_1, w_2, w_3, w_4) &= P(w_1) P(w_2 \mid w_1) P(w_3 \mid w_2) P(w_4 \mid w_3) ,
P(w_1, w_2, w_3, w_4) &= P(w_1) P(w_2 \mid w_1) P(w_3 \mid w_1, w_2) P(w_4 \mid w_2, w_3) .
\end{aligned}
$$
当$n$较小时,$n$元语法往往并不准确。例如,在一元语法中,由三个词组成的句子“你走先”和“你先走”的概率是一样的。然而,当$n$较大时,$n$元语法需要计算并存储大量的词频和多词相邻频率。
>思考:$n$元语法可能有哪些缺陷?
1.参数空间过大
2.数据稀疏
1.4 时序数据的采样
在训练中我们需要每次随机读取小批量样本和标签。与之前章节的实验数据不同的是,时序数据的一个样本通常包含连续的字符。假设时间步数为5,样本序列为5个字符,即“想”“要”“有”“直”“升”。该样本的标签序列为这些字符分别在训练集中的下一个字符,即“要”“有”“直”“升”“机”,即$X$=“想要有直升”,$Y$=“要有直升机”。
现在我们考虑序列“想要有直升机,想要和你飞到宇宙去”,如果时间步数为5,有以下可能的样本和标签:
- $X$:“想要有直升”,$Y$:“要有直升机”
- $X$:“要有直升机”,$Y$:“有直升机,”
- $X$:“有直升机,”,$Y$:“直升机,想”
- …
- $X$:“要和你飞到”,$Y$:“和你飞到宇”
- $X$:“和你飞到宇”,$Y$:“你飞到宇宙”
- $X$:“你飞到宇宙”,$Y$:“飞到宇宙去” 可以看到,如果序列的长度为$T$,时间步数为$n$,那么一共有$T-n$个合法的样本,但是这些样本有大量的重合,我们通常采用更加高效的采样方式。我们有两种方式对时序数据进行采样,分别是随机采样和相邻采样。
随机采样
在随机采样中,每个样本是原始序列上任意截取的一段序列,相邻的两个随机小批量在原始序列上的位置不一定相毗邻。
相邻采样
在相邻采样中,相邻的两个随机小批量在原始序列上的位置相毗邻。
二、Encoder-Decoder
针对输入输出不等价来进行先编码再解码 encoder:输入到隐藏状态 decoder:隐藏状态到输出
观察decoder的template代码可以发现decoder包含一个初始化输入状态的函数,当decoder输出<eos>
等结束符则代表结束:
class Decoder(nn.Module):
def __init__(self, **kwargs):
super(Decoder, self).__init__(**kwargs)
def init_state(self, enc_outputs, *args):
raise NotImplementedError
def forward(self, X, state):
raise NotImplementedError
encoder-decoder 模式常用于NLP领域的对话系统、生成式任务(生成歌词)、翻译。
Seq2Seq 模型初探
训练模式:
预测模式:
具体结构:
注意这里存在Embedding
层用来将进行词嵌入,由单词获得词向量。一般情况下输入到编码网络中的数据不是一个onehot向量而是经过了编码之后的向量,比如由word2vec技术,让编码后的向量由更加丰富的含义。
损失函数的计算时需要考虑句子的有效长度,这里介绍序列掩膜的函数来计算有效部分的损失和加入掩膜的交叉熵损失函数。
def SequenceMask(X, X_len,value=0):
maxlen = X.size(1)
mask = torch.arange(maxlen)[None, :].to(X_len.device) < X_len[:, None]
X[~mask]=value
return X
class MaskedSoftmaxCELoss(nn.CrossEntropyLoss):
# pred shape: (batch_size, seq_len, vocab_size)
# label shape: (batch_size, seq_len)
# valid_length shape: (batch_size, )
def forward(self, pred, label, valid_length):
# the sample weights shape should be (batch_size, seq_len)
weights = torch.ones_like(label)
weights = SequenceMask(weights, valid_length).float()
self.reduction='none'
output=super(MaskedSoftmaxCELoss, self).forward(pred.transpose(1,2), label)
return (output*weights).mean(dim=1)
Beam Search
Seq2Seq模型的每个词的输出都是在整个词的字典空间中先确定分数最高的词,然后再组成整个句子,然而这样并没有对输出的整个句子的通顺程度做进一步的衡量,只依赖hidden state能够学习到上下文。通过大量的实验和应用,人们发现只考虑单个词的优化是不足够的。那如何找到最好的句子呢?
一般的贪心搜索(greedy search)方法会在整个字典空间内选择最优解。如果一句话的长度为$N$个单词,单词的字典容量为$K$.则搜索空间为$K^N$,这种搜索方法叫做维特比算法.
集束搜索则以时间序列去进行逐步搜索,每时间步上只取top-n
的候选结果,最终获得$n$个搜索出的候选结果,$n$称为beam size
,过程如图所示: