专栏首页Datawhale专栏深度神经网络在NLP的应用!

深度神经网络在NLP的应用!

作者:张泽,华东师范大学,Datawhale优秀学习者

深度学习正在给自然语言处理带来巨大的变革,例如机器翻译,情感分析,问答系统等落地实践。深度学习对NLP的影响主要有如下几点:

  • 神经网络结构对自然语言处理领域的影响最深刻;
  • 深度学习可以对自然语言处理的各个层面制定学习任务;
  • 密集词汇表示的重要性和学习表示的方法。

今天,我们以文本分类为实践,详解深度神经网络在自然语言处理中的应用。

1. 数据及背景

https://tianchi.aliyun.com/competition/entrance/531810/information(阿里天池-零基础入门NLP赛事)

2. 文本表示

传统离散式文本表示主要是one-hot编码以及tf-idf等编码方式,但往往存在以下问题:

  • 无法衡量词向量之间的关系。
  • 词表的维度随着语料库的增长而膨胀。
  • n-gram词序列随语料库增长呈指数型膨胀,更加快。
  • 离散数据来表示文本会带来数据稀疏问题,导致丢失了信息,与我们生活中理解的信息是不一样的。

为了解决前面提到的离散表示文本存在的问题,我们接下来介绍文本的分布式表示。

2.1 词嵌入

将所有词语投影到K维的向量空间,每个词语都可以用一个K维向量表示。好处是Distributed representation — word embedding:这种非稀疏表示的向量很容易求它们之间的距离(欧式、余弦等),从而判断词与词语义上的相似性,也就解决了one-hot方法表示两个词之间的相互独立的问题。

下面介绍的Word2Vec属于NNLM模型,即神经网络语言模型(Neural Network Language model),它是通过训练得到词向量矩阵,这就是我们要得到的文本表示向量矩阵。NNLM说的是定义一个前向窗口大小,把这个窗口中最后一个词当做y,把之前的词当做输入x。通俗来说就是预测这个窗口中最后一个词出现概率的模型。

2.2 Word2vec

Word2Vec模型是一种简单化的神经网络,用于对文本的分布式表示。它包含了两个训练模型(Skip-gram,CBOW)以及两种加速的方法(Hierarchical Softmax,Negative Sampling)。

2.2.1 CBOW 模型

CBOW模型是在已知当前词上下文context的前提下预测当前词w(t),类似阅读理解中的完形填空。

2.2.2 Skip-Gram 模型

相反,Skip-Gram模型是在已知当前词w(t)的前提下,预测上下文context。Skip-gram 模型的目标是最大化对数条件概率:

\large \arg \max _{\theta} \prod_{w \in DICT}\left[\prod_{c \in C(w)} p(c | w ; \theta)\right]

里面的条件概率即softmax函数:

\large \operatorname{softmax}\left(u_{o}^{T} v_{c}\right)=p(o | c)=\hat{y}_{o}=\Large \frac{e^{u_{o}^{T} v_{c}}}{\sum_{w=1}^{W} e^{u_{w}^{T} v_{c}}}

u是权重(也是这个词的词向量),v就是所要迭代的向量,将该式子代入上式并取log可以得到:

\arg \max _{\theta} \sum_{(w, c) \in D} \log p(c | w)=\sum_{(w, c) \in D}\left(\log e^{v_{c} \cdot v_{w}}-\log \sum_{c^{\prime}} e^{v_{c^{\prime}} \cdot v_{w}}\right)

对上述目标函数求最大化,可以让相似的词具有相似的向量值。

Softmax 分母是词汇表大小的求和,其复杂度比较高,需要提出快速又有效的算法,于是就有了Negtive sampling 以及 Hierarchical Softmax。

2.2.3 Negative Sampling

正例:中心词是 w,它周围上下文共有2c个词,记为 。由于这个中心词 ,的确和 相关存在,因此它是一组正例。

负例:通过负采样,我们得到 (负采样的个数)个和 w 不同的中心词wi,i=1,2,..neg,这样 和 就组成了 组并不真实存在的负例。

数据:

  • 正:x:context(w),label:
  • 负:x:context(w),label:,。

利用这一个正例和 个负例,我们进行二元逻辑回归(可以理解成一个二分类问题),得到负采样对应每个词 对应的模型参数以及每个词的词向量。

J(\theta)=\frac{1}{T} \sum_{t=1}^{T} J_{t}(\theta) \\ J_{t}(\theta)=\log \sigma\left(u_{o}^{T} v_{c}\right)+\sum_{\small i = 1}^{k} \mathbb{E}_{j} P(w)\left[\log \sigma\left(-u_{j}^{T} v_{c}\right)\right]

更新的参数 = 正的对数概率 - 负的对数概率的期望(服从词频分布)

上述问题可以看成二分类问题,于是我们得出有目标函数:

\arg \max _{\theta} \sum_{(w, c) \in D} \log \sigma\left(v_{e} \cdot v_{w}\right)+\sum_{(w, c) \in D^{\prime}} \log \sigma\left(-v_{e} \cdot v_{w}\right)

2.2.4 Hierarchical Softmax

输入层到投影层是把输入层的所有向量进行加和给投影层,比如,输入的是三个4维词向量:(1,2,3,4),(9,6,11,8),(5,10,7,12),那么我们word2vec映射后的归一化词向量就是(5,6,7,8),对CBOW模型来说,就是把上下文词向量加和,然而,对于Skip-Gram模型来说就是简单的传值。

最后的输出是构建一颗哈夫曼树,如何去构造简单的哈夫曼树。在这里不在累述;在这里,哈夫曼树的所有叶子节点是词表中的所有词,权值是每个词在词表中出现的次数,也就是词频。

一般得到哈夫曼树后我们会对叶子节点进行哈夫曼编码,由于权重高的叶子节点越靠近根节点,而权重低的叶子节点会远离根节点,这样我们的高权重节点编码值较短,而低权重值编码值较长。这保证的树的带权路径最短,也符合我们的信息论,即我们希望越常用的词(词频越高的词)拥有更短的编码,一般的编码规则是左0右1,但是这都是人为规定的,word2vec中正好采用了相反的编码规则,同时约定左子树的权重不小于右子树的权重。在word2vec中,采用了二元逻辑回归的方法,即规定沿着左子树走,那么就是负类(哈夫曼树编码1),沿着右子树走,那么就是正类(哈夫曼树编码0)。

2.2.5 Word2Vec存在的问题

  • 对每个local context window单独训练,没有利用包含在global中的统计信息。
    • 改进:Glove,用了全局的信息(共线矩阵)
  • 对多义词无法很好的表示和处理,因为使用了唯一的词向量。
    • 改进:Gaussian Embedding,将词向量加入一个高斯噪音,方差由词频决定。

2.2.6 FastText 和 Word2vec 中的层次 softmax 区别

我们之前学习的 FastText 中输出层也用到了层次 softmax,但CBOW的叶子节点是词和词频,而 FastText 的叶子节点是类标和类标的频数。两者本质不同,体现在 h-softmax 的使用:

Word2Vec

FastText

输入

one-hot形式的单词的向量

embedding过的单词的词向量和n-gram向量

输出

对应的是每一个term,计算某term概率最大

对应的是分类的标签。

word2vec 的目的是得到词向量,该词向量最终是在输入层得到的,输出层对应的 h-softmax 也会生成一系列的向量,但是最终都被抛弃,不会使用。

FastText 则充分利用了 h-softmax 的分类功能,遍历分类树的所有叶节点,找到概率最大的 label

2.2.7 Word2vec实践代码

我们知道在 Tf-Idf 的训练时,可以将训练集和测试集一起进行训练,那么 Word2vec 也是一样的,以下是实现方式:

# 构造word2vec的输入,corpus
all_df=train.append(test)
all_df["corpus"]=all_df.text.apply(lambda x:x.split(" "))
corpus=all_df.corpus.values 

# 开始训练
def hashfxn(x):
    return int(x)
    
logging.basicConfig(
 format='%(asctime)s : %(levelname)s : %(message)s', 
 level=logging.INFO
)
model = Word2Vec(
 corpus,workers=multiprocessing.cpu_count(),
 size=300,sg=1,hs=0,negative=5,
 hashfxn=hashfxn,min_count=3,iter=5
)

# 保存
model.wv.save_word2vec_format("../data/word2vec.bin",binary=True)
# 读取
w2v_path="../data/word2vec.bin"
word_vectors = gensim.models.KeyedVectors.load_word2vec_format(w2v_path, binary=True)

3. 文本分类

3.1 TextCNN

TextCNN利用CNN(卷积神经网络)进行文本特征抽取,不同大小的卷积核分别抽取n-gram特征,卷积计算出的特征图经过MaxPooling保留最大的特征值,然后将拼接成一个向量作为文本的表示。

这里我们基于TextCNN原始论文的设定,分别采用了100个大小为2,3,4的卷积核,最后得到的文本向量大小为100*3=300维。

3.1.1 维度分析

input 层(m句sentence,句子长度为n) :m=batch_size → 进入 embedding 层 , 变为 m×n×300 。Conv2d 只能做四维卷积,要把第1维unsqueeze,, 变为 m×1×n×300,这里也可以尝试Conv1d → 进入 conv 层 被卷积,一个大小为(2,300) 的二维卷积核 之后变为 m×1×(n-1)×1(无padding),若是大小为3变为 m×1×(n-2)×1(无padding) → 进入 pooling 层 ,每一个向量 变为 m×1×1,取了300个卷积核(out_channels),所以维度为 m×1×300 → 进入 classification 层,输出各标签的概率 m×num_class。

细节:在batch处理的时候,每个batch一起计算,为了保证kernel卷积出来的长度相等,所以每个batch padding成一个长度几乎是必需的。

3.1.2 ModelEmbedding 类细节

首先,我们构造了 ModelEmbedding 类来解决预训练词向量的预处理。

我们在词典里加入了 PADDINGUNKNOWN 两个字符,对应之后 padding_value=0,和 getitem 里面的UNKNOWN,当然,还要构造词典和词典长度的获取。

    def __init__(self, w2v_path):
        # 加载词向量矩阵
        self.word2vector = gensim.models.KeyedVectors.load_word2vec_format(w2v_path, binary=True)
        # 输入的总词数
        self.vocabs = self.word2vector.vocab.keys()
        special_token_list = ["<PADDING>", "<UNKNOWN>"]
        self.input_word_list = special_token_list + self.word2vector.index2word
        self.embedding = np.concatenate((np.random.rand(2, 300), self.word2vector.vectors))
        self.dict_length = len(self.input_word_list)
        self.get_word2id()
        
    def get_word2id(self):
        # word2id词典
        self.word2id = dict(zip(self.input_word_list, list(range(len(self.input_word_list)))))

3.1.3 Dataset 类细节

其次,我们构造了一个 Dataset 类 和一个 GetLoader 类来获取输入网络的batch。这里的句子长度实在时不好统一,所以我们采用 max_length 进行截断,但是这里只是我主观的想法,并没有实际尝试截断多少为最好的。

def __getitem__(self, item):
        temp = []
        for word in self.corpus[item]:
            if word in self.model_embedding.word2id:
                temp.append(self.model_embedding.word2id[word])
            else:
                temp.append(self.model_embedding.word2id['<UNKNOWN>'])
        if len(temp) > self.max_length:
            temp = temp[:self.max_length]
        if self.with_label:
            return torch.tensor(temp), self.corpus_label[item]
        else:
            return torch.tensor(temp)

3.1.4 GetLoader 类& collate_fn 细节:

这里需要注意collect_fn的细节,测试集不能被打乱,所以这里针对不同的数据集构造了不同的 collect_fn

    def get_iter(self):
        def collate_fn_train(batch_data):
            batch_data.sort(key=lambda xi: len(xi[0]), reverse=True)
            data_length = [len(xi[0]) for xi in batch_data]
            sent_seq = [xi[0] for xi in batch_data]
            label = [xi[1] for xi in batch_data]
            padded_sent_seq = pad_sequence(sent_seq, batch_first=True, padding_value=0)
            return padded_sent_seq, data_length, torch.tensor(label, dtype=torch.long)

        def collate_fn_test(batch_data):
            data_length = [len(xi) for xi in batch_data]
            sent_seq = [xi for xi in batch_data]
            padded_sent_seq = pad_sequence(sent_seq, batch_first=True, padding_value=0)
            return padded_sent_seq, data_length

        self.train_loader = DataLoader(self.train_dataset, batch_size=64, collate_fn=collate_fn_train)
        self.valid_loader = DataLoader(self.valid_dataset, batch_size=256, collate_fn=collate_fn_train)
        self.test_loader = DataLoader(self.test_dataset, batch_size=256, collate_fn=collate_fn_test)

最后我们构造了 TextCNN 类进行模型的搭建,有需要的可以去Github自行提取,这里提一下 预训练词向量载入的方法。

3.1.5 TextCNN 类细节

预载入的词向量pretrained_word_vectors,就是我们刚才构造的 ModelEmbedding.embedding,和直接用 gensim 的载入的唯一区别就是我们多了两个单词 PADDINGUNKNOWN,他们被随机初始化了。

def init_weights(self, pretrained_word_vectors, is_static=False):
    self.embedding.weight = nn.Parameter(torch.from_numpy(pretrained_word_vectors).float())
    if is_static:
        self.embedding.weight.requires_grad = False
    else:
        self.embedding.weight.requires_grad = True

最后,只要套用训练模板进行训练即可,这里展示一下跑程序的参考流程

if __name__ == "__main__":
    data_root = {
        "train_path": './data/train_torch.csv',
        "test_path": "./data/test_a.csv",
        "sub_path": "./data/test_a_sample_submit.csv",
        "w2v_path": "./data/word2vec.bin"
    }
    config = GetInit(data_root)
    model_embedding = ModelEmbedding(data_root["w2v_path"])

    train_dataset = MyDataset(model_embedding,
                              corpus=config.x_train,
                              corpus_label=config.y_train,
                              with_label=True)
    test_dataset = MyDataset(model_embedding,
                             corpus=config.x_test,
                             with_label=False)

    loader = GetLoader(train_dataset, test_dataset)

    # 建立model
    model = TextCNN(
     vocab_size=model_embedding.dict_length, 
     embedding_dim=300, 
     output_size=14
    )
    model.init_weights(model_embedding.embedding, is_static=False)
    model = model.cuda()
    criterion = nn.NLLLoss()
    opt = torch.optim.Adam(model.parameters(), lr=1e-4)

    # 开始训练
    mytrain = TrainFunc(model, criterion, opt, loader.train_loader, loader.valid_loader, loader.test_loader)
    best_model = mytrain.train(20)

最后使用了10%的训练集作为验证集,在线上无调参的TextCNN得分为0.937,冲入了前30,可见Word2vec+TextCNN 还是非常不错的一个模型,当然我这里没有取定随机数,shake 还是挺大的。

3.2 TextRNN

有了上面的框架之后我们只需要改掉 我们的分类器TextCNN变为 TextRNN 即可。让我们来做一下维度分析:

3.2.1 维度分析

这里我们首先采用已经 padding 和截断过的长度,所以每个batch都是一样的。(因为不用写代码……)

input 层(m句sentence,句子长度为n) :m=batch_size → 进入 embedding 层 , 变为 m×n×300 。→ 进入 LSTM 变为 m×n×hidden_size, 这里的hidden_sizeLSTM输出的维度 → 我们只取最后一个长度作为输出,维度变为 m×hidden_size→ 进入 classification 层(全连接层),输出各标签的概率 m× num_class

3.2.2 LSTM复习

写到这,我不禁想默写一下 LSTM 的公式。

\begin{array}{ll} \\ i_t = \sigma(W_i [x_t, h_{t-1}]) \\ f_t = \sigma(W_f [x_t, h_{t-1}]) \\ g_t = \tanh(W_g [x_t, h_{t-1}]) \\ o_t = \sigma(W_o [x_t, h_{t-1}]) \\ c_t = f_t * c_{(t-1)} + i_t * g_t \\ h_t = o_t * \tanh(c_t) \\ \end{array}

3.2.3 TextRNN代码实践

我们的完整代码只需要更改分类器TextRNN即可,实践代码已经上传至Github,可能需要改大一点的lr,LSTM 的前几个epoch 太不好 train,这种是一个比较简单的网络层。

  • 注意到LSTM的输出,其实我们只要第一项output,但是我觉得拿 hn 也可以,总之都差不多,因为output是所有的h。
  • 在全连接层之前,还要取最后一个的输出,out[:, -1, :]
  • 注意全连接的输入维度和双向单向有关系,双向要翻倍。
  • 教材里有个维度交换的过程,是用来调整 batch_first 的顺序把,但是我这里验了一下觉得没有必要,所以就没换。
  • 看到有些同学初始化了一下 h0 和 c0,我觉得不错,但是我这里就不用了,我喜欢代码简洁一点。
class TextRNN(nn.Module):
    def __init__(self, vocab_size, embedding_dim, hidden_size=100, output_size=14, dropout=0.5):
        super(TextRNN, self).__init__()
        self.embedding = nn.Embedding(vocab_size, embedding_dim)

        self.word_lstm = nn.LSTM(input_size=embedding_dim,
                                 hidden_size=hidden_size,
                                 num_layers=1,
                                 batch_first=True,
                                 bidirectional=True)

        self.dropout = nn.Dropout(dropout)
        self.fc = nn.Linear(hidden_size * 2, output_size)

    def init_weights(self, pre_trained_word_vectors, is_static=False):
        self.embedding.weight = nn.Parameter(torch.from_numpy(pre_trained_word_vectors).float())
        if is_static:
            self.embedding.weight.requires_grad = False
        else:
            self.embedding.weight.requires_grad = True

    def forward(self, inputs):
        out = self.embedding(inputs)
        out, _ = self.word_lstm(out)
        out = self.dropout(out)
        out = self.fc(out[:, -1, :])
        return F.log_softmax(out, 1)

3.2.4 TextRNN实践结果

不理想,LSTM 迭代速度较慢是老问题了,学习率比TextCNN要大一点,但是在验证集上只能达到0.92,所以没有提交,猜想是padding和LSTM不搭的关系,顺便学习一下torch 如何输入变长序列,结果一开始彻底没有train起来,后来通过小数据调参,找到了问题,用RMSprop替换 Adam 最后训练成功,真是玄学啊。

3.2.5 变长序列

细节有三点,一个是模型的输入要变,第二个是模型的处理要变,第三个是长度截断。

  • 模型输入:输入从单纯的矩阵变成了一个打包了数据和长度的东西,我们要把长度和数据都传给模型,这样他才能从padding好的数据里,找到自己需要的结尾。
from torch.nn.utils.rnn import pack_padded_sequence 
# 老版本是torch.nn.utils.rnn.rnn_utils.pack_padded_sequence 

for batch_idx, (data, length, label) in enumerate(iter(self.train_iter)):
    batch_x_pack = pack_padded_sequence(data, length, batch_first=True)
    batch_x_pack = batch_x_pack.cuda()
    label = label.cuda()
    output = self.model(batch_x_pack)
  • 模型处理:主要是forward里面的处理,先把打包的数据拆了,让数据 data 过 Embedding 层和 Lstm层,在取输出的时候用 length 的列表去取。
  • 需要注意这里新建立了一个tensor,所以GPU 要过 cuda(),其他都是相似的。
  • 或者像我一样使用Pytorch 花式索引,让代码更简洁。
    # def forward(self, inputs):
    #     out = self.embedding(inputs)
    #     out, _ = self.word_lstm(out)
    #     out = self.dropout(out)
    #     out = self.fc(out[:, -1, :])
    #     return F.log_softmax(out, 1)

    def forward(self, x):
        _pad, _len = pad_packed_sequence(x, batch_first=True)
        x_embedding = self.embedding(_pad)
        # x_embedding = x_embedding.view(x_embedding.shape[0], x_embedding.shape[1], -1)
        output, _ = self.word_lstm(x_embedding)
        _len = _len - 1  # 每个元素-1
        
        #temp = []
        #for i in range(len(_len)):
        #    temp.append(output[i, _len[i], :].tolist())
        #temp = torch.tensor(temp).cuda()
        
  temp = output[range(len(_len)),_len,:] 
  # pytorch花式索引与上面注释掉的等价
  
        out = self.fc(temp)
        return F.log_softmax(out, 1)
  • 长度截断:我个人认为可以使用更长的长度,但是模型并没有更好,甚至没有 train 起来,仔细想想长度太长的确训练不起来,这里不能使用分词,以字为单位,LSTM效果的确不理想。

3.2.6 TextCNN与TextRNN 总结

在匿名字符集上,TextCNN 感觉思路上比RNN 更高一筹,训练速度更是比 RNN快,但是CNN一定要padding,就让人觉得很变扭。

3.2.7 得分总结

TextCNN:线下 0.938 ,线上0.937 TextRNN 等长:线下 0.932,线上未提交。TextRNN 变长:线下 0.928,线上未提交。

3.3 HAN

3.3.1 结构

HAN的结构与 TextCNN或者TextRNN 是两个不一样层面的东西,最重要的体现是他们的输入不一样。HAN的文本层次是这样的:词→句→文。而TextCNN或者TextRNN的文本层次为:词→文。所以迥异的结构也导致了预处理代码要重新整理一下,要进行分句,但是这里是匿名数据集,都是匿名字符,所以要做一点EDA实验,才能很好的分句。

3.3.2 维度分析

HAN的结构为5层:我们结合维度分析来拆解。关于输入,我们的句子数和句子长度,都是被截断过和padding过的,所以在每个batch都是等长的。

input 层(b个文档,m句sentence,句子长度为n) :b=batch_size → 进入 embedding 层 , 变为 b×m×n×300 。→ 进入 GRU1 word层 变为 b×m×n×hidden_size1, 这里的hidden_size1GRU1输出的维度,我们这里取全部步长,而不再是最后一个长度。→ 进入 Attention1 word层 变为 b×m×hidden_size1,将n个单词综合,代表了句子的向量表示。→ 进入 GRU2 sentence层 变为 b×m×hidden_size2, 这里的hidden_size2GRU2输出的维度。→ 进入 Attention2 sentence层 变为 b×hidden_size2,将m个句子综合,代表文章的向量表示。→ 进入 classification 层(全连接层),输出各标签的概率 b× num_class

所以,可以很容易知道,他的结构层次更多一点,而我们只要学会了Attention 层,就可进行这个实现,当然,关于变长序列还是要在花点功夫,建议了解一下 Pytorch 之 PackedSequence 。

3.3.3 Attention

Attention的本质是要找到输入的feature的权重分布。

Attention的参数

如果我们输入一个长为 n 的 feature,那么 Attention 就要学习一个长为 n 的分布weight,这个分布代表着feature 中每个元素的权重,显然这个weight 是要被归一化的(sum=1),而且他就是我们要学习的参数,它是由通过计算相似度实现的。

Attention的返回

Attention 并不返回他学到参数,定义输入的feature 为 H ,学习的参数为A,代表权重分布。

H=[h_1,....,h_n]\\ A=[a_1,...,a_n]

那么他的返回即为s,代表这次学习到的参数的分数,也是在HAN中的句子或者文章的向量表示。

s=\sum_i a_i h_i

HAN中的Attention细节

Attention 有很多种计算方式,有soft ,hard,genral,concat,self ……等等等,我们这里学习的是HAN的论文中的公式。

以 word-att 层为例:句子长度为n 我们的输入为 GRU1层的output ,维度可以看做为 n×hidden_size1,定义为 H,外面的 b×m 其实只要看成一个for 循环就可以了,或者看成长度为 b×m×n 也没问题 。

第一步:线性变换:H 变为了 U,维度可能会变换,但是只是线性变换。

u_{i}=\tanh \left(W_{w} h_{i}+b_{w}\right)\\ H=[h_1,....,h_n]^T,\text {dimH}=n*h1 \\ U=[u_1,....,u_n]^T,\text {dimU}=n*p \\ \text {dim} u_i= p*1

第二步:学习参数:随机初始化辅助参数 ,他的维度和 一样的,通过和 来学习权重参数A。这里已经利用了 softmax 归一化A。

\alpha_{i}=\frac{\exp \left(u_{i}^{\top} u_{w}\right)}{\sum_{i} \exp \left(u_{i}^{\top} u_{w}\right)} \in R\\ A=[a1,…,an],\text {dimA}=n*1

最后得到的A长度也和我们的输入吻合。

第三步:计算score,即为我们要返回的东西。

s=\sum_{i} \alpha_{i} h_{i}

总之,中间有一个向量点积计算相似度的过程,所以一定要确保点积运算的那个东西是向量,不然会报错(后注:事实上是用矩阵直接算的 ),这使得实现起来会非常的困难,我看到的比较多的实现都是三层的层级关系,工程量很大。

其实,我们已经知道最后A的维度了,反正 都是随机初始化的,那么实现的时候也可以用一个线性层直接转换到需要的维度(1)再进行softmax(很多代码都是这样实现的),不过这样好像就不是论文中的样子了。

学习权重的公式就变成了:

\alpha_{i}=\frac{\exp (W_1U+b)}{\sum_{i} \exp (W_1U+b)} \in R\\ A=[a1,…,an]

代码实践

代码已经上传至Github

如果按照论文中的实现,我们需要构造三个层级关系,难度较高,所以使用比较简单的实现方式,更新后的Attention代码就和论文中一样了,不过他是针对三维输入的,不能直接套进我自己的代码中。

class HAN_Attention_revise(nn.Module):
    """
    HAN的attention更新,dot做法
    sentence_level:inputsize=b*l*300,outsize=b*300        
    """
    def __init__(self,in_size=300,hidden_size=200):
        super(HAN_Attention_revise,self).__init__()
        self.layer1=nn.Linear(in_size,hidden_size)
        self.us=nn.Parameter(torch.FloatTensor(hidden_size),requires_grad=True) # 200

    def forward(self,X):
        X1 = X.view(X.shape[1],X.shape[0],X.shape[2]) # l前置, l*b*300
        U = torch.tanh(self.layer1(X1)) # 构造U l*b*200
        A = (U*self.us).sum(2) # 点积,维度 U*self.us  = l*b*200
        A = F.softmax(A.T,1).unsqueeze(2)  # softmax操作+转置,  b*l*1  第1维归一化
        return(X*A).sum(1)

下面是更新前的代码,可以针对4维输入和3维输入做attention,相似度计算方式为mlp。

class Attention(nn.Module):
    def __init__(self,in_size,hidden_size):
        super(Attention,self).__init__()
        self.layer1=nn.Linear(in_size,hidden_size)
        self.layer2=nn.Linear(hidden_size,1)

    def forward(self,X):
        out = self.layer1(X)
        out = torch.tanh(out)
        out = self.layer2(out)
        out = F.softmax(out,dim=-2)
        return out.mul(X).sum(-2)

我们保证在倒数第二维上归一化,在倒数第二维上求和,这样就不用在写一遍代码了。我们可以测试一下,符合预期。

# test1
p=torch.rand(64,30,254,300)
att=Attention(in_size=300,hidden_size=200)
A=att(p)
print(A.shape)
# output
torch.Size([64, 30, 300])

# test2
p=torch.rand(64,30,100)
att=Attention(in_size=100,hidden_size=50)
A=att(p)
print(A.shape)
# output
torch.Size([64, 100])

3.3.4 HAN代码

class HAN(nn.Module):
    def __init__(self,vocab_size, embedding_dim,hidden_size=100, output_size=14, dropout=0.5):
        super(HAN,self).__init__()
        self.embedding = nn.Embedding(vocab_size, embedding_dim)
        self.gru1 = nn.GRU(embedding_dim, hidden_size, bidirectional=True, batch_first=True)
        self.att1 = Attention(hidden_size*2,128)
        self.gru2 = nn.GRU(hidden_size*2, hidden_size//2, bidirectional=True, batch_first=True)
        self.att2 = Attention(hidden_size,64)
        self.dropout = nn.Dropout(dropout)
        self.fc = nn.Linear(hidden_size, output_size)
    
    def init_weights(self, pretrained_word_vectors,is_static=False):
        self.embedding.weight = nn.Parameter(torch.from_numpy(pretrained_word_vectors).float())
        if is_static:
            self.embedding.weight.requires_grad = False
        else:
            self.embedding.weight.requires_grad = True
    
    def forward(self,X):
        X = self.embedding(X)
        out = X.view(X.shape[0]*X.shape[1],X.shape[2], -1).contiguous()
        out, _ = self.gru1(out)
        out = out.view(X.shape[0],X.shape[1],X.shape[2], -1).contiguous()
        out = self.att1(out)
        out, _ = self.gru2(out)
        out = self.att2(out)
        out = self.dropout(out)
        out = self.fc(out)
        return F.log_softmax(out, 1)

细节:由于我们Embedding 是个 4维向量, GRU接受不了,所以我们在word级别的时候,把前两维先合并,然后再拆开。让我不禁感叹,我做了半天的预处理结果还是要合并。

3.3.5 数据预处理

没有做EDA,直接就以 3750 | 900 | 648 三个数字一起切句子,设定每篇文章最大句子数为40,每个句子最大单词数为40,也就是说我们的输入维度一定是batchsize×40×40,这里的超参数明显是可以调的。至于怎么做二维padding,研究了好久,最终还是以比较丑陋的代码写了出来,就不放出来了,代码也发出来了,可以自行查看(改了Dataset类)。

3.3.6 其他细节

  • HAN 的训练速度比较慢,但是效率还是很高的,几个epoch就能到好的数值。
  • 使用了梯度裁剪,不知道有没有起有效的作用。
  • 还是只有 RMSProp 能 train 起来。
  • 增加了一个lr_schedule,把玩一下。
  • 这一次将train_batch的 shuffle=True,以前不shuffle是想做对比。
  • HAN线下成绩:9437,线上成绩:0.9426,冲入了前15,还是很强的。

本文PDF电子版教程 后台回复 文本分类 获取

本文分享自微信公众号 - Datawhale(Datawhale),作者:张泽

原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。

原始发表时间:2020-08-14

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • NLP经典算法复现!CRF原理及实现代码

    寄语:本文先对马尔可夫过程及隐马尔可夫算法进行了简单的介绍;然后,对条件随机场的定义及其三种形式进行了详细推导;最后,介绍了条件随机场的三大问题,同时针对预测问...

    Datawhale
  • CV入门赛最全思路&上分技巧汇总!

    https://tianchi.aliyun.com/competition/entrance/531795/introduction(阿里天池-零基础入门CV...

    Datawhale
  • 本科生晋升GM记录 & Kaggle比赛进阶技巧分享

    去年9月中的时候,刚上大四不久,之前一直对热衷于DL的我,其实都只是在自学看书,学习一些理论知识,但动手实践非常少,框架也只是会一些Tensorflow/Ker...

    Datawhale
  • python实现坦克大战

    砸漏
  • PaddleX助力无人驾驶:基于YOLOv3的车辆检测和车道线分割实战

    无人驾驶汽车利用传感器技术、信号处理技术、通讯技术和计算机技术等,通过集成视觉、激光雷达、超声传感器、微波雷达、GPS、里程计、磁罗盘等多种车载传感器来辨识汽车...

    用户1386409
  • 用python实现漂亮的烟花demo

    py3study
  • iOS原生的搜索:UISearchController

    王大锤
  • Python Web Flask源码解读(二)——路由原理

    在 Flask中是使用 @app.route这个装饰器来实现 url和方法之间的映射的。

    阳仔
  • Python定做一个计算器,小而美哒~

    qt 设计器提供的常用控件基本都能满足开发需求,通过拖动左侧的控件,很便捷的就能搭建出如下的UI界面,比传统的手写控件代码要方便很多。

    double
  • python GUI库图形界面开发之PyQt5拖放控件实例详解

    在GUI中,拖放指的是点击一个对象,并将其拖动到另一个对象上的动作。比如百度云PC客户端支持的拖放文件以快速移动文件:

    砸漏

扫码关注云+社区

领取腾讯云代金券