文章目录
Character-based convolutional encoder for NMT
使用字符级卷积获得词向量
(a) 在assignment 4 中我们使用的词向量是256维,而在本assignment 中,我们使用的字符的嵌入为50维,解释为什么通常字符级的嵌入比词嵌入更低。
- 我是这么理解的,嵌入表示了所携带的信息(例如可能含有词性信息,语义信息等),很明显一个单词比起一个字符所携带的信息更多,因此使用更高维表示。还有一个单词由很多字符构成,直观上字符的嵌入维度应该比词嵌入维度要低一些。
(b)
- character-based embedding model:
嵌入层: e w o r d = 50 e_{word} = 50 eword=50
卷积核参数: W ∈ R f × e c h a r × k W \in R^{f× e_{char} ×k} W∈Rf×echar×k,和一个bias b ∈ R f b\in R^{f} b∈Rf
HighWay层: W p r o j , W g a t e ∈ R e w o r d × e w o r d W_{proj},W_{gate} \in R^{e_{word} × e_{word}} Wproj,Wgate∈Reword×eword和两个偏置 b p r o j , b g a t e ∈ R e w o r d b_{proj},b_{gate} \in R^{e_{word}} bproj,bgate∈Reword
因此参数一共为 V c h a r ∗ e w o r d + f ∗ e c h a r ∗ k + f + 2 ∗ e w o r d ∗ e w o r d + 2 ∗ e w o r d = 96 ∗ 50 + 256 ∗ 50 ∗ 5 + 256 + 2 ∗ 50 ∗ 50 + 2 ∗ 50 = 74156 V_{char} * e_{word} + f * e_{char} * k + f + 2*e_{word}*e_{word} + 2*e_{word}=96*50+256*50*5+256+2*50*50+2*50=74156 Vchar∗eword+f∗echar∗k+f+2∗eword∗eword+2∗eword=96∗50+256∗50∗5+256+2∗50∗50+2∗50=74156 - word-based lookup embedding model:
嵌入层: V w o r d ∗ e w o r d = 50000 ∗ 256 = 12800000 V_{word} * e_{word} = 50000*256 = 12800000 Vword∗eword=50000∗256=12800000 - word-based lookup embedding model含有的参数更多,两者的倍数关系为172倍
(c) RNN计算每个位置的上下文表示时,将从左到右或从右到左获得的信息“装进”一个固定长度的向量中,而卷积只是利用window中的几个词或字符来获得一个输出,因此卷积不仅可以并行计算,还可以使用多个filter来捕获不同的特征,拼接在一起可以获得不同长度的表示,可以使用多头注意力的思想,使用不同的attention head 去捕获不同的特征。
(d) 最大池化:
- 优点:将显著的特征保留下来了
- 缺点:大部分信息被丢弃
平均池化:
- 优点:将数据中所有信息都保留下来了
- 缺点:进行平均后,那些较明显的特征将会被“稀释”
模型架构
完整代码见我的Github
(e) 实现 words2charindices(),即单词向字符下标的转化,参照下面的 words2indices() 函数即可,这里要注意要在每个单词前后增加 start_of_word
和 end_of_word
字符
return [[[self.start_of_word]+[self.char2id[c] for c in w]+[self.end_of_word] for w in s] for s in sents]
(f) 实现 pad_sents_char(),即将每个单词pad成最长单词长度(21),并且对每个句子进行pad,pad成batch中最长句子长度
sents_padded = []
max_len = max(len(s) for s in sents)
for s in sents:
pad_sent = []
for w in s: #for each word
if len(w)<max_word_length:
pad_sent.append(w+[char_pad_token]*(max_word_length-len(w))) #pad word
else:
pad_sent.append(w[:max_word_length]) #truncate word
if len(pad_sent) < max_len: #pad sentence with max_len_word
if max_len>len(pad_sent):
pad_sent.extend([char_pad_token]*max_word_length for i in range(max_len-len(pad_sent)))
sents_padded.append(pad_sent)
(g) 使用上面实现的两个函数实现 to_input_tensor_char() ,并根据要求对维度进行调整
sents = self.words2charindices(sents) #word_ids (list[list[list[int]]])
tensor = pad_sents_char(sents,self.char2id['<pad>']) #list (batch_size,max_len,max_word_len)
tensor = torch.tensor(tensor,dtype=torch.long,device = device)
return tensor.permute([1,0,2])
(h) 实现Highway,其输入为卷积层的输出。构建两个线性层,一个用于对输入线性变换后,再激活得到激活值。另一个对输入进行线性变换后,使用sigmoid,用于控制输出中哪些部分是激活值,哪些部分是原输入。
class Highway(nn.Module):
def __init__(self,e_word):
""" initial two Linear to construct highway connection
@param (int):the dimention of the word
"""
super(Highway,self).__init__()
self.proj = nn.Linear(e_word, e_word)
self.gate = nn.Linear(e_word, e_word)
def forward(self,conv_out:torch.Tensor) -> torch.Tensor:
""" highway connection
@param conv_out (torch.Tensor): the convolution layer's output,its shape is (batch_size, e_word)
@return highway_out (torch.Tensor): tensor of (batch_size, e_word)
"""
x_proj = F.relu(self.proj(conv_out))
x_gate = torch.sigmoid(self.gate(conv_out))
highway_out = torch.mul(x_proj,x_gate) + torch.mul(conv_out,1-x_gate)
return highway_out
可以通过给模型参数赋予特定的值来进行测试模型输出的最终结果是否为期望结果,具体可以参考前几个assignment的sanity_check.py ,下面初始化模型参数的一个例子
由于模型简单,我们这里只简单验证下输出的形状是否满足要求
if __name__ == '__main__':
high = Highway(5)
input = torch.randn(4,5)
pred = high(input)
assert(pred.shape==input.shape)
(i) 实现CNN
- 我们这里使用一维卷积,需要了解 nn.Conv1d(),nn.MaxPool1d().
class CNN(nn.Module):
def __init__(self,e_char,filter_size,m_word = 21,kernel_size = 5):
"""initial cnn
@param filter_size (int): the number of filter,also the e_word
@param e_char (int): the demention of char
@param kernel_size (int): the filter's length
"""
super(CNN,self).__init__()
self.conv1d = nn.Conv1d(e_char,filter_size,kernel_size)
self.pool = nn.MaxPool1d(m_word - kernel_size + 1)
def forward(self,reshaped) -> torch.Tensor:
"""
@param reshaped (torch.Tensor): the char embedding of sentences, which is tensor of (batch_size,e_char,max_word_len)
@return conv_out (torch.Tensor):the ouput of cnn, which is tensor of (bat_size,e_word)
"""
conv_out = F.relu(self.conv1d(reshaped))
conv_out = self.pool(conv_out)
return conv_out.squeeze(-1) #最后一个维度被maxpool了,因此将最后一个维度去掉
这里依旧简单验证下输出维度是否满足
if __name__ == '__main__':
cnn = CNN(50,4) #(char_embedding, filter_number or word_embedding)
input = torch.randn(10,50,21) #(batch_size or words, char_embedding, max_word_len)
assert(cnn(input).shape==(10,4))
(j) 实现 ModelEmbeddings 中的 __init__ 和 forward
- forward() 需要将 x p a d d e d x_{padded} xpadded 转换为 x w o r d _ e m b x_{word\_emb} xword_emb
- __init__中需要定义 Embedding,可以自动求导,来训练字符嵌入
__init__:
self.pad_token_idx = vocab.char2id['<pad>']
self.dropout_rate = 0.3
self.e_char = 50
self.embed_size = embed_size
self.cnn = CNN(self.e_char,embed_size)
self.highway = Highway(self.embed_size)
self.dropout = nn.Dropout(self.dropout_rate)
self.char_embedding = nn.Embedding(len(vocab.char2id),self.e_char,self.pad_token_idx)
forward():
word_embedding = []
for words in input: #拿出所有句子的第一个单词 (batch_size,max_word_length)
char_embedd = self.char_embedding(words) #get all words' embedding (batch_size, max_word_length,e_char)
reshaped = char_embedd.permute([0,2,1]) #(batch_size,e_char,max_word_length)
conv_out = self.cnn(reshaped) # 对词进行cnn,maxpool (batch_size,e_word)
highway = self.dropout(self.highway(conv_out)) #(sentence_size,e_word)
word_embedding.append(highway)
word_emb = torch.stack(word_embedding) #默认dim = 0 (sentence_length,batch_size,embed_size)
return word_emb
- 具体解释请看代码中的注释
(k) 实现NMT中的 forward(),只需要将原来的to_input_tensor
换成to_input_tensor_char
即可,这样得到的就是List[List[List[int]]]
得到的tensor。即使用用字符表示的句子,然后在 encode 中使用 ModelEmbeddings 将会获得用字符级嵌入得到的词嵌入。
source_padded_chars = self.vocab.src.to_input_tensor_char(source,device=self.device) #int(max_sentence_length, batch_size, max_word_length)
target_padded = self.vocab.tgt.to_input_tensor(target, device=self.device)
target_padded_chars = self.vocab.tgt.to_input_tensor_char(target,device = self.device)
enc_hiddens, dec_init_state = self.encode(source_padded_chars, source_lengths)
enc_masks = self.generate_sent_masks(enc_hiddens, source_lengths)
combined_outputs = self.decode(enc_hiddens, enc_masks, dec_init_state, target_padded_chars)
(l) 将其他代码补全(从a4中复制过来即可),进行本地训练与测试
- 起初训练得到了一个很好的结果,满足题目要求了
- 结果测试时分数很差
- 查看输出的翻译也非常不好
- 找了半天的bug,一直以为哪个地方维度调整错误了,或者是别的什么原因,结果到最后发现 原来是历史遗留问题,NMT 中的 step函数 写错了一句代码,
state,o_t,_ = self.step(Ybar_t, dec_state, enc_hiddens, enc_hiddens_proj, enc_masks)
这里的state 错了,每个时间步都需要使用上一个时间步输出的隐藏状态,所以应该改为
dec_state,o_t,_ = self.step(Ybar_t, dec_state, enc_hiddens, enc_hiddens_proj, enc_masks)
写代码还是得细心。。。 - 最后重新训练,测试的结果如下
Character-based LSTM decoder for NMT
构建一个基于LSTM的字符级解码器,当遇到<UNK>时,使用此解码器来生成OOV词汇。
几个重要的说明
- CharDecoderLSTM 起始 h 0 , c 0 h_0,c_0 h0,c0使用 combined output vector,即上一时间步产生的隐藏层状态 + 注意力加权,其中 + 表示拼接。然后让每步得到的隐藏层状态 h i h_i hi 通过一个线性层,输出的维度与字符数目相同。最后使用softmax求得每个位置取每个字符的概率
- 训练时对 target sentence 中所有单词进行训练,不仅仅是用 <UNK> 表示的单词,损失函数使用交叉熵损失函数,一个单词的所有位置(每个字符)求和,再对整个batch单词求和(而不求平均),并且与基于单词模型的loss相加。同时训练基于单词的模型,和基于字符的decoder。
- 测试时,首先使用基于单词的NMT系统,使用beam search 翻译出 target sentence,若存在 <UNK> 则使用基于字符的decoder产生单词, h 0 , c 0 h_0,c_0 h0,c0 使用combined ouput vector,使用greedy search,即选择每个位置概率最大的字符作为结果。当产生 <END> 时,基于字符的decoder输出产生的单词
(a) 完成 CharDecoder 的 __init__
super(CharDecoder,self).__init__()
self.padding_idx = target_vocab.char2id['<pad>']
self.charDecoder = nn.LSTM(char_embedding_size,hidden_size)
self.char_output_projection = nn.Linear(hidden_size, len(target_vocab.char2id)) #将隐藏层输出维度调整为字符表大小,用于预测
self.decoderCharEmb = nn.Embedding(len(target_vocab.char2id),char_embedding_size,padding_idx = self.padding_idx) #字符嵌入
self.target_vocab = target_vocab
(b) 完成char_decoder.py 中的 forward() 函数
X = self.decoderCharEmb(input) #(length,batch_size,embedding_size)
hidden_states,dec_hidden = self.charDecoder(X,dec_hidden) #(length,batch_size,hidden_size) (batch_size,hidden_size)
scores = self.char_output_projection(hidden_states) #(length,batch_size,vocab_size)
return scores,dec_hidden
(c) 完成 train_forward(),计算loss
scores,dec_hidden = self.forward(char_sequence[:-1],dec_hidden) #(length,batch_size,vocab_size)
entroy = nn.CrossEntropyLoss(ignore_index = self.padding_idx,reduction = 'sum') #不计算padding位置,将所有loss求和
loss = entroy(scores.permute(1,2,0),char_sequence[1:].permute(1,0))
#remove 'START' (batch_size,vocab_size,length) (batch_size,length) 第一维和最后一维要相同
return loss
具体看代码中的注释,尤其要知道nn.CrossEntropyLoss() 的用法
(d) 完成 decode_greedy()
decodeWords = []
words = []
start_char = self.target_vocab.start_of_word
end_char = self.target_vocab.end_of_word
batch_size = initialStates[0].shape[1]
current_char = torch.tensor([[start_char] * batch_size],device = device) #(len:1, batch_size)
dec_state = initialStates
for _ in range(max_length):
scores,dec_state = self.forward(current_char,dec_state) #(len:1,batch_size,vocab_size)
current_char = scores.argmax(-1) #(len:1, batch_size)
words.append(current_char)
words = torch.cat(words).permute(1,0) #(batch_size, len)
for w in words:
word = ""
for ch in w:
if ch == end_char:
break
word += self.target_vocab.id2char[int(ch)]
decodeWords.append(word)
return decodeWords
这里使用的LSTM,而每个时间步的输入都是上个时间步的输出,因此我们需要一个时间步一个时间步的计算,因此输入维度为 (len:1,batch_size,vocab_size),为了方便batch操作,在解码时,没有遇到<END>就结束。因此我们在拼接单词时,遇到<END>就结束,将得到的单词保存下来。
(e) 本地训练与测试:
- 同样,自己从 run.sh 中粘贴命令执行
- 训练:
python run.py train --train-src=./en_es_data/train_tiny.es --train-tgt=./en_es_data/train_tiny.en --dev-src=./en_es_data/dev_tiny.es --dev-tgt=./en_es_data/dev_tiny.en --vocab=vocab_tiny_q2.json --batch-size=2 --max-epoch=201 --valid-niter=100
- 测试:首先新建文件夹 outputs 并且新建
outputs/test_outputs_local_q2.txt
python run.py decode model.bin ./en_es_data/test_tiny.es ./en_es_data/test_tiny.en outputs/test_outputs_local_q2.txt
- 训练时出现了一个错误,提示tensor不连续,
target_chars = target_padded_chars[1:].view(-1, max_word_len)
这里target_padded_chars的维度是(max_sentence_length, batch_size, max_word_length)。
在使用view前应保证tensor是连续的,因此使用contiguous()
target_chars = target_padded_chars[1:].contiguous().view(-1, max_word_len)
(f)
Analyzing NMT Systems
(a) 下面的表是西班牙单词 traducir 的各种形式以及与英语的对应,从 vocab.json 中找出哪些形式存在,解释为什么这对基于单词的NMT系统不友好,为什么对基于字符的NMT系统有好处?
- 只有 traducir:5152 和traduce:8764 存在,当源语言句子进行嵌入时,只有traduce才有对应的词向量,而其他形式将会被当作 <UNK>进行嵌入,因此训练会有偏差。而基于字符没有OOV问题,能够将源语言句子通过字符嵌入的方式得到每个词的嵌入。而这些形式的前六个字符大多都是相同的(除了traduzco),因此我们的CNN的某些 filter 会学习到这样的字符组合是翻译的意思,而末尾的字符表示了不同的形式(比如主语的不同,时态的不同等)
(b) 将Word2Vec 和我们NMT使用CharCNN训练出词向量进行比较,下面这个网站可以可视化。
- 这个网站真的很不错,将所有词放到一个三维空间中,并且每个词都可以查看。
- 网址:https://projector.tensorflow.org/
(i) 下面是word2vec中一些词的最接近的词
- 同时在图中还会显示单词位置
(ii) 下载我们CharCNN训练好的词向量,上传查看
(iii) 比较两种词向量,简要形容word2vec和charCNN分别建模了什么相似性,解释两者所用方法的差异是如何导致这两种结果的差异的。
- word2vec:对词义相似性进行建模
- charCNN:对词形相似性进行建模
- 原因:因为 word2vec 方法认为词义相似的单词具有相似的上下文,而charCNN是通过基于window的特征提取来建模,因此结构上相似的单词在特征空间里距离更近
(c) 找出一个使用char-based decoder 产生可接受的翻译,而基于单词的模型产生<UNK>的例子,和一个基于字符的解码器也没有产生可接受翻译的例子
这里需要使用训练模型,无机器且训练时间较长,以后再补。
总结
- 本次实验比起之前的实验要麻烦一些,之前的实验代码注释里都有详细的Hint,告诉你怎样完成某个函数。还给了需要学会的重要函数的doc链接,而本次实验只告诉你需要完成什么功能,甚至需要自己完成两个类。
- 完成了两个类 CNN 和 Highway,学会了这两个神经网络中重要组件的实现
- 本次实验是在 assignment 4 的基础上进行了修改,增加了字符级的编码和解码部分。增加了两个我们自己实现的两个类,对重要函数进行了修改,仅此而已。我们搭建神经网络时,可以学习学习,将各个部分分开,如果要对架构进行修改的话,不至于修改很多代码。有点像软件构造中的内容。
- 大的层面上解决不了,从小的层面进行尝试。在编码阶段,有些OOV单词无法被编码成特定词向量,只能用<UNK>来替代。既然单词是由字母组成的,那我们是不是可以考虑,用字母组合的一些规律来编码单词,即使用CNN(当然也可以使用RNN)来获得单词嵌入。在解码阶段也是一样,只能产生<UNK>,不能产生单词,那我们是否可以产生字母来组合成单词。换一个层面来考虑,有时候也能解决问题。
- 对矩阵的维度的含义更加熟悉了,对矩阵维度的变化也更加熟练了。