NLP以赛代练 Task5:基于深度学习的文本分类 2

 


学习目标

  • 学习Word2Vec的使用和基础原理
  • 学习使用TextCNN、TextRNN进行文本表示
  • 学习使用HAN网络结构完成文本分类
     

文本表示方法 Part3

本篇将继续学习基于深度学习的文本分类。html

 


词向量

本节经过word2vec学习词向量。word2vec模型背后的基本思想是对出如今上下文环境里的词进行预测。对于每一条输入文本,咱们选取一个上下文窗口和一个中心词,并基于这个中心词去预测窗口里其余词出现的几率。所以,word2vec模型能够方便地重新增语料中学习到新增词的向量表达,是一种高效的在线学习算法(online learning)。python

word2vec的主要思路:经过单词和上下文彼此预测,对应的两个算法分别为:web

  • Skip-grams (SG):预测上下文算法

  • Continuous Bag of Words (CBOW):预测目标单词数组

另外提出两种更加高效的训练方法:网络

  • Hierarchical softmaxapp

  • Negative samplingdom

 


1. Skip-grams原理和网络结构

Word2Vec模型中,主要有Skip-Gram和CBOW两种模型,从直观上理解,Skip-Gram是给定input word来预测上下文。而CBOW是给定上下文,来预测input word。iphone


Word2Vec模型实际上分为了两个部分,第一部分为创建模型,第二部分是经过模型获取嵌入词向量。svg

Word2Vec的整个建模过程实际上与自编码器(auto-encoder)的思想很类似,即先基于训练数据构建一个神经网络,当这个模型训练好之后,咱们并不会用这个训练好的模型处理新的任务,咱们真正须要的是这个模型经过训练数据所学得的参数,例如隐层的权重矩阵——后面咱们将会看到这些权重在Word2Vec中实际上就是咱们试图去学习的“word vectors”。

Skip-grams过程,假如咱们有一个句子“The dog barked at the mailman”。

  1. 首先咱们选句子中间的一个词做为咱们的输入词,例如咱们选取“dog”做为input word;

  2. 有了input word之后,咱们再定义一个叫作skip_window的参数,它表明着咱们从当前input word的一侧(左边或右边)选取词的数量。若是咱们设置skip_window=2,那么咱们最终得到窗口中的词(包括input word在内)就是[‘The’, ‘dog’,‘barked’, ‘at’]。skip_window=2表明着选取左input word左侧2个词和右侧2个词进入咱们的窗口,因此整个窗口大小span=2x2=4。另外一个参数叫num_skips,它表明着咱们从整个窗口中选取多少个不一样的词做为咱们的output word,当skip_window=2,num_skips=2时,咱们将会获得两组 (input word, output word) 形式的训练数据,即 (‘dog’, ‘barked’),(‘dog’, ‘the’)。

  3. 神经网络基于这些训练数据将会输出一个几率分布,这个几率表明着咱们的词典中的每一个词做为input word的output word的可能性。这句话有点绕,咱们来看个例子。第二步中咱们在设置skip_window和num_skips=2的状况下得到了两组训练数据。假如咱们先拿一组数据 (‘dog’, ‘barked’) 来训练神经网络,那么模型经过学习这个训练样本,会告诉咱们词汇表中每一个单词当’dog’做为input word时,其做为output word的可能性。

也就是说模型的输出几率表明着到咱们词典中每一个词有多大可能性跟input word同时出现。例如:若是咱们向神经网络模型中输入一个单词“Soviet“,那么最终模型的输出几率中,像“Union”, ”Russia“这种相关词的几率将远高于像”watermelon“,”kangaroo“非相关词的几率。由于”Union“,”Russia“在文本中更大可能在”Soviet“的窗口中出现。

咱们将经过给神经网络输入文本中成对的单词来训练它完成上面所说的几率计算。下面的图中给出了一些咱们训练样本的例子。咱们选定句子“The quick brown fox jumps over lazy dog”,设定咱们的窗口大小为2(window_size=2),也就是说咱们仅选输入词先后各两个词和输入词进行组合。下图中,蓝色表明input word,方框内表明位于窗口内的单词。

 




咱们的模型将会从每对单词出现的次数中习得统计结果。例如,咱们的神经网络可能会获得更多相似(“Soviet“,”Union“)这样的训练样本对,而对于(”Soviet“,”Sasquatch“)这样的组合却看到的不多。所以,当咱们的模型完成训练后,给定一个单词”Soviet“做为输入,输出的结果中”Union“或者”Russia“要比”Sasquatch“被赋予更高的几率。

PS:input word和output word都会被咱们进行one-hot编码。仔细想一下,咱们的输入被one-hot编码之后大多数维度上都是0(实际上仅有一个位置为1),因此这个向量至关稀疏,那么会形成什么结果呢。若是咱们将一个1 x 10000的向量和10000 x 300的矩阵相乘,它会消耗至关大的计算资源,为了高效计算,它仅仅会选择矩阵中对应的向量中维度值为1的索引行:

2. Skip-grams训练

由上部分可知,Word2Vec模型是一个超级大的神经网络(权重矩阵规模很是大)。例如:咱们拥有10000个单词的词汇表,咱们若是想嵌入300维的词向量,那么咱们的输入-隐层权重矩阵和隐层-输出层的权重矩阵都会有 10000 x 300 = 300万个权重,在如此庞大的神经网络中进行梯度降低是至关慢的。更糟糕的是,你须要大量的训练数据来调整这些权重而且避免过拟合。百万数量级的权重矩阵和亿万数量级的训练样本意味着训练这个模型将会是个灾难

解决方案:

  • 将常见的单词组合(word pairs)或者词组做为单个“words”来处理

  • 对高频次单词进行抽样来减小训练样本的个数

对优化目标采用“negative sampling”方法,这样每一个训练样本的训练只会更新一小部分的模型权重,从而下降计算负担

2.1 Word pairs and “phases”

一些单词组合(或者词组)的含义和拆开之后具备彻底不一样的意义。好比“Boston Globe”是一种报刊的名字,而单独的“Boston”和“Globe”这样单个的单词却表达不出这样的含义。所以,在文章中只要出现“Boston Globe”,咱们就应该把它做为一个单独的词来生成其词向量,而不是将其拆开。一样的例子还有“New York”,“United Stated”等。

在Google发布的模型中,它自己的训练样本中有来自Google News数据集中的1000亿的单词,可是除了单个单词之外,单词组合(或词组)又有3百万之多。

2.2 对高频词抽样

在上一部分中,对于原始文本为“The quick brown fox jumps over the laze dog”,若是使用大小为2的窗口,那么咱们能够获得图中展现的那些训练样本。


可是对于“the”这种经常使用高频单词,这样的处理方式会存在下面两个问题:

  1. 当咱们获得成对的单词训练样本时,(“fox”, “the”) 这样的训练样本并不会给咱们提供关于“fox”更多的语义信息,由于“the”在每一个单词的上下文中几乎都会出现

  2. 因为在文本中“the”这样的经常使用词出现几率很大,所以咱们将会有大量的(”the“,…)这样的训练样本,而这些样本数量远远超过了咱们学习“the”这个词向量所需的训练样本数

Word2Vec经过“抽样”模式来解决这种高频词问题。它的基本思想以下:对于咱们在训练原始文本中遇到的每个单词,它们都有必定几率被咱们从文本中删掉,而这个被删除的几率与单词的频率有关。

ωi 是一个单词,Z(ωi) 是 ωi 这个单词在全部语料中出现的频次,例如:若是单词“peanut”在10亿规模大小的语料中出现了1000次,那么 Z(peanut) = 1000/1000000000 = 1e - 6。

P(ωi) 表明着保留某个单词的几率:

2.3 Negative sampling

训练一个神经网络意味着要输入训练样本而且不断调整神经元的权重,从而不断提升对目标的准确预测。每当神经网络通过一个训练样本的训练,它的权重就会进行一次调整。

因此,词典的大小决定了咱们的Skip-Gram神经网络将会拥有大规模的权重矩阵,全部的这些权重须要经过数以亿计的训练样原本进行调整,这是很是消耗计算资源的,而且实际中训练起来会很是慢。

负采样(negative sampling)解决了这个问题,它是用来提升训练速度而且改善所获得词向量的质量的一种方法。不一样于本来每一个训练样本更新全部的权重,负采样每次让一个训练样本仅仅更新一小部分的权重,这样就会下降梯度降低过程当中的计算量。

当咱们用训练样本 ( input word: “fox”,output word: “quick”) 来训练咱们的神经网络时,“ fox”和“quick”都是通过one-hot编码的。若是咱们的词典大小为10000时,在输出层,咱们指望对应“quick”单词的那个神经元结点输出1,其他9999个都应该输出0。在这里,这9999个咱们指望输出为0的神经元结点所对应的单词咱们称为“negative” word。

当使用负采样时,咱们将随机选择一小部分的negative words(好比选5个negative words)来更新对应的权重。咱们也会对咱们的“positive” word进行权重更新(在咱们上面的例子中,这个单词指的是”quick“)。

PS: 在论文中,做者指出指出对于小规模数据集,选择5-20个negative words会比较好,对于大规模数据集能够仅选择2-5个negative words。

咱们使用“一元模型分布(unigram distribution)”来选择“negative words”。个单词被选做negative sample的几率跟它出现的频次有关,出现频次越高的单词越容易被选做negative words。

每一个单词被选为“negative words”的几率计算公式:


其中 f(ωi)表明着单词出现的频次,而公式中开3/4的根号彻底是基于经验的。

在代码负采样的代码实现中,unigram table有一个包含了一亿个元素的数组,这个数组是由词汇表中每一个单词的索引号填充的,而且这个数组中有重复,也就是说有些单词会出现屡次。那么每一个单词的索引在这个数组中出现的次数该如何决定呢,有公式,也就是说计算出的负采样几率*1亿=单词在表中出现的次数。

有了这张表之后,每次去咱们进行负采样时,只须要在0-1亿范围内生成一个随机数,而后选择表中索引号为这个随机数的那个单词做为咱们的negative word便可。一个单词的负采样几率越大,那么它在这个表中出现的次数就越多,它被选中的几率就越大。

3. Hierarchical Softmax

3.1 霍夫曼树

输入:权值为(w1,w2,…wn)的n个节点

输出:对应的霍夫曼树

  1. 将(w1,w2,…wn)看作是有n棵树的森林,每一个树仅有一个节点

  2. 在森林中选择根节点权值最小的两棵树进行合并,获得一个新的树,这两颗树分布做为新树的左右子树。新树的根节点权重为左右子树的根节点权重之和

  3. 将以前的根节点权值最小的两棵树从森林删除,并把新树加入森林

  4. 重复步骤 2 和 3 直到森林里只有一棵树为止

下面咱们用一个具体的例子来讲明霍夫曼树创建的过程,咱们有(a,b,c,d,e,f)共6个节点,节点的权值分布是(16,4,8,6,20,3)。

首先是最小的b和f合并,获得的新树根节点权重是7.此时森林里5棵树,根节点权重分别是16,8,6,20,7。此时根节点权重最小的6,7合并,获得新子树,依次类推,最终获得下面的霍夫曼树。


那么霍夫曼树有什么好处呢?通常获得霍夫曼树后咱们会对叶子节点进行霍夫曼编码,因为权重高的叶子节点越靠近根节点,而权重低的叶子节点会远离根节点,这样咱们的高权重节点编码值较短,而低权重值编码值较长。这保证的树的带权路径最短,也符合咱们的信息论,即咱们但愿越经常使用的词拥有更短的编码。如何编码呢?通常对于一个霍夫曼树的节点(根节点除外),能够约定左子树编码为0,右子树编码为1。如上图,则能够获得c的编码是00。

在word2vec中,约定编码方式和上面的例子相反,即约定左子树编码为1,右子树编码为0,同时约定左子树的权重不小于右子树的权重。

更多原理可参考:霍夫曼树原理

3.2 Hierarchical Softmax过程

为了不要计算全部词的softmax几率,word2vec采样了霍夫曼树来代替从隐藏层到输出softmax层的映射。

霍夫曼树的创建:

  • 根据标签(label)和频率创建霍夫曼树(label出现的频率越高,Huffman树的路径越短)

  • Huffman树中每一叶子结点表明一个label


如上图所示:



注意:此时的theta是一个待定系数,它是由推导最大似然以后求解获得迭代式子。

使用 gensim 训练 word2vec

from gensim.models.word2vec import Word2Vec
model = Word2Vec(sentences, workers=num_workers, size=num_features)

参考:

  1. CS224n笔记2 词的向量表示:word2vec

  2. 斯坦福大学深度学习与天然语言处理第二讲:词向量

  3. (Stanford CS224d) Deep Learning and NLP课程笔记(三):GloVe与模型的评估

  4. http://www.cnblogs.com/pinard/p/7249903.html

  5. https://blog.csdn.net/yinkun6514/article/details/79218736

  6. https://www.leiphone.com/news/201706/PamWKpfRFEI42McI.html

 


TextCNN

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

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


 

TextRNN

TextRNN利用RNN(循环神经网络)进行文本特征抽取,因为文本自己是一种序列,而LSTM自然适合建模序列数据。TextRNN将句子中每一个词的词向量依次输入到双向双层LSTM,分别将两个方向最后一个有效位置的隐藏层拼接成一个向量做为文本的表示。


 

基于TextCNN、TextRNN的文本表示

TextCNN

  • 模型搭建
self.filter_sizes = [2, 3, 4]  # n-gram window
self.out_channel = 100
self.convs = nn.ModuleList([nn.Conv2d(1, self.out_channel, (filter_size, input_size), bias=True) for filter_size in self.filter_sizes])
  • 前向传播
pooled_outputs = []
for i in range(len(self.filter_sizes)):
    filter_height = sent_len - self.filter_sizes[i] + 1
    conv = self.convs[i](batch_embed)
    hidden = F.relu(conv)  # sen_num x out_channel x filter_height x 1

    mp = nn.MaxPool2d((filter_height, 1))  # (filter_height, filter_width)
    # sen_num x out_channel x 1 x 1 -> sen_num x out_channel
    pooled = mp(hidden).reshape(sen_num, self.out_channel)
    
    pooled_outputs.append(pooled)

TextRNN

  • 模型搭建
input_size = config.word_dims

self.word_lstm = LSTM(
    input_size=input_size,
    hidden_size=config.word_hidden_size,
    num_layers=config.word_num_layers,
    batch_first=True,
    bidirectional=True,
    dropout_in=config.dropout_input,
    dropout_out=config.dropout_hidden,
)
  • 前向传播
hiddens, _ = self.word_lstm(batch_embed, batch_masks)  # sent_len x sen_num x hidden*2
hiddens.transpose_(1, 0)  # sen_num x sent_len x hidden*2

if self.training:
    hiddens = drop_sequence_sharedmask(hiddens, self.dropout_mlp)

 


使用HAN用于文本分类

Hierarchical Attention Network for Document Classification(HAN) 基于层级注意力,在单词和句子级别分别编码并基于注意力得到文档的表示,而后通过Softmax进行分类。其中word encoder的做用是得到句子的表示,能够替换为上节提到的TextCNN和TextRNN,也能够替换为下节中的BERT。


 


做业

  • 尝试经过Word2Vec训练词向量
  • 尝试使用TextCNN、TextRNN完成文本表示
  • 尝试使用HAN进行文本分类
import logging
import random

import numpy as np
import torch

logging.basicConfig(level=logging.INFO, format='%(asctime)-15s %(levelname)s: %(message)s')

# set seed
seed = 666
random.seed(seed)
np.random.seed(seed)
torch.cuda.manual_seed(seed)
torch.manual_seed(seed)
# split data to 10 fold
fold_num = 10
data_file = '../data/train_set.csv'
import pandas as pd


def all_data2fold(fold_num, num=10000):
    fold_data = []
    f = pd.read_csv(data_file, sep='\t', encoding='UTF-8')
    texts = f['text'].tolist()[:num]
    labels = f['label'].tolist()[:num]

    total = len(labels)

    index = list(range(total))
    np.random.shuffle(index)

    all_texts = []
    all_labels = []
    for i in index:
        all_texts.append(texts[i])
        all_labels.append(labels[i])

    label2id = {}
    for i in range(total):
        label = str(all_labels[i])
        if label not in label2id:
            label2id[label] = [i]
        else:
            label2id[label].append(i)

    all_index = [[] for _ in range(fold_num)]
    for label, data in label2id.items():
        # print(label, len(data))
        batch_size = int(len(data) / fold_num)
        other = len(data) - batch_size * fold_num
        for i in range(fold_num):
            cur_batch_size = batch_size + 1 if i < other else batch_size
            # print(cur_batch_size)
            batch_data = [data[i * batch_size + b] for b in range(cur_batch_size)]
            all_index[i].extend(batch_data)

    batch_size = int(total / fold_num)
    other_texts = []
    other_labels = []
    other_num = 0
    start = 0
    for fold in range(fold_num):
        num = len(all_index[fold])
        texts = [all_texts[i] for i in all_index[fold]]
        labels = [all_labels[i] for i in all_index[fold]]

        if num > batch_size:
            fold_texts = texts[:batch_size]
            other_texts.extend(texts[batch_size:])
            fold_labels = labels[:batch_size]
            other_labels.extend(labels[batch_size:])
            other_num += num - batch_size
        elif num < batch_size:
            end = start + batch_size - num
            fold_texts = texts + other_texts[start: end]
            fold_labels = labels + other_labels[start: end]
            start = end
        else:
            fold_texts = texts
            fold_labels = labels

        assert batch_size == len(fold_labels)

        # shuffle
        index = list(range(batch_size))
        np.random.shuffle(index)

        shuffle_fold_texts = []
        shuffle_fold_labels = []
        for i in index:
            shuffle_fold_texts.append(fold_texts[i])
            shuffle_fold_labels.append(fold_labels[i])

        data = {'label': shuffle_fold_labels, 'text': shuffle_fold_texts}
        fold_data.append(data)

    logging.info("Fold lens %s", str([len(data['label']) for data in fold_data]))

    return fold_data


fold_data = all_data2fold(10)
# build train data for word2vec
fold_id = 9

train_texts = []
for i in range(0, fold_id):
    data = fold_data[i]
    train_texts.extend(data['text'])
    
logging.info('Total %d docs.' % len(train_texts))
logging.info('Start training...')
from gensim.models.word2vec import Word2Vec

num_features = 100     # Word vector dimensionality
num_workers = 8       # Number of threads to run in parallel

train_texts = list(map(lambda x: list(x.split()), train_texts))
model = Word2Vec(train_texts, workers=num_workers, size=num_features)
model.init_sims(replace=True)

# save model
model.save("./word2vec.bin")
# load model
model = Word2Vec.load("./word2vec.bin")

# convert format
model.wv.save_word2vec_format('./word2vec.txt', binary=False)

 


结语

介绍了Word2Vec的使用,以及TextCNN、TextRNN的原理和训练,最后介绍了用于长文档分类的HAN。