返回

专栏 自然语言处理第一番之文本分类器

文本分类应该是自然语言处理中最普遍的一种应用,例如文章自动分类、邮件自动分类、垃圾邮件识别、用户情感分类等等,在生活中有很多例子,本文主要从传统和深度学习两块来向大家介绍如何构建一个文本分类器。

文本分类方法

传统的文本方法的主要流程是人工设计一些特征,从原始文档中提取特征,然后指定分类器如 LR、SVM,训练模型对文章进行分类,比较经典的特征提取方法如频次法、tf-idf、互信息方法、N-Gram。

深度学习兴起之后,也有很多人开始使用一些经典的模型如 CNN、LSTM 这类方法来做特征的提取,在本文中,作者会简要描述一下各种方法,及其在文本分类任务中的一些实验。

传统文本分类方法

这里主要描述两种特征提取方法:频次法、tf-idf、互信息、N-Gram。

  • 频次法

频次法,顾名思义,十分简单。它记录每篇文章的次数分布,然后将分布输入机器学习模型,训练一个合适的分类模型。对这类数据进行分类,需要指出的是:在统计次数分布时,可合理提出假设,频次比较小的词对文章分类的影响比较小。因此,我们可合理地假设阈值,滤除频次小于阈值的词,减少特征空间维度。

  • TF-IDF

TF-IDF 相对于频次法,有更进一步的考量。词出现的次数能从一定程度反应文章的特点,即 TF,而 TF-IDF,增加了所谓的反文档频率,如果一个词在某个类别上出现的次数多,而在全部文本上出现的次数相对比较少,我们就认为这个词有更强大的文档区分能力。TF-IDF 是综合考虑了频次和反文档频率两个因素的方法。

  • 互信息方法

互信息方法也是一种基于统计的方法,计算文档中出现词和文档类别的相关程度,即互信息。

  • N-Gram

基于 N-Gram 的方法是把文章序列,通过大小为 N 的窗口,形成一个个 Group。然后对这些 Group 做统计,滤除出现频次较低的 Group,再把这些 Group 组成特征空间,传入分类器,进行分类。

深度学习方法

  • 基于 CNN 的文本分类方法
  • 最普通的基于 CNN 的方法就是 Keras 上的 example 做情感分析,接 Conv1D,指定大小的 window size 来遍历文章,加上一个 maxpool。如此多接入几个,得到特征表示,然后加上 FC,进行最终的分类输出。
  • 基于 CNN 的文本分类方法,最出名的应该是 2014 Emnlp 的 Convolutional Neural Networks for Sentence Classification,使用不同 filter 的 cnn 网络,然后加入 maxpool,然后 concat 到一起。

论文链接:http://www.aclweb.org/anthology/D14-1181

  • 这类 CNN 的方法,通过设计不同的 window size 来建模不同尺度的关系,但是很明显,丢失了大部分的上下文关系,论文《Recurrent Convolutional Neural Networks for Text Classification》对此进行了研究。将每一个词形成向量化表示时,加上上文和下文的信息,每一个词的表示如下:

整个结构框架如下:

如针对这句话「A sunset stroll along the South Bank affords an array of stunning vantage points」,stroll 的表示包括 c_l(stroll),pre_word2vec(stroll),c_r(stroll), c_l(stroll) 编码 A sunset 的语义,而 c_r(stroll) 编码 along the South Bank affords an array of stunning vantage points 的信息,每一个词都如此处理,因此会避免普通 cnn 方法的上下文缺失的信息。

  • 基于 LSTM 的方法
  • 和基于 CNN 的方法中第一种类似,直接暴力地在 embedding 之后加入 LSTM,然后输出到一个 FC 进行分类,基于 LSTM 的方法,我觉得这也是一种特征提取方式,可能比较偏向建模时序的特征;
  • 在暴力的方法之上,如论文《A C-LSTM Neural Network for Text Classification》的研究,将 embedding 输出不直接接入 LSTM,而是接入到 CNN,通过 CNN 得到一些序列,然后吧这些序列再接入到 LSTM,文章说这么做会提高最后分类的准去率。

代码实践

语料及任务介绍

训练的语料来自于大概 31 个新闻类别的新闻语料,但是其中有一些新闻数目比较少,所以取了数量比较多的前 20 个新闻类比的新闻语料,每篇新闻稿字数从几百到几千不等,任务就是训练合适的分类器然后将新闻分为不同类别:

Bow

Bow 对语料处理,得到 tokens set:

def __get_all_tokens(self):
    """ get all tokens of the corpus    """
    fwrite = open(self.data_path.replace("all.csv","all_token.csv"), 'w')    with open(self.data_path, "r") as fread:
        i = 0
        # while True:
        for line in fread.readlines():            try:
                line_list = line.strip().split("\t")
                label = line_list[0]
                self.labels.append(label)
                text = line_list[1]
                text_tokens = self.cut_doc_obj.run(text)
                self.corpus.append(' '.join(text_tokens))
                self.dictionary.add_documents([text_tokens])
                fwrite.write(label+"\t"+"\\".join(text_tokens)+"\n")
                i+=1
            except BaseException as e:
                msg = traceback.format_exc()                print msg                print "=====>Read Done<======"
                break
    self.token_len = self.dictionary.__len__()    
print "all token len "+ str(self.token_len)
    self.num_data = i
    fwrite.close()
复制

然后,tokens set 以频率阈值进行滤除,然后对每篇文章做处理来进行向量化:

def __filter_tokens(self, threshold_num=10):
    small_freq_ids = [tokenid for tokenid, docfreq in self.dictionary.dfs.items() if docfreq < threshold_num ]
    self.dictionary.filter_tokens(small_freq_ids)
    self.dictionary.compactify()def vec(self):
    """ vec: get a vec representation of bow    """
    self.__get_all_tokens()    
print "before filter, the tokens len: {0}".format(self.dictionary.__len__())
    self.__filter_tokens()    
print "After filter, the tokens len: {0}".format(self.dictionary.__len__())
    self.bow = []    
for file_token in self.corpus:
        file_bow = self.dictionary.doc2bow(file_token)
        self.bow.append(file_bow)    # write the bow vec into a file
    bow_vec_file = open(self.data_path.replace("all.csv","bow_vec.pl"), 'wb')
    pickle.dump(self.bow,bow_vec_file)
    bow_vec_file.close()
    bow_label_file = open(self.data_path.replace("all.csv","bow_label.pl"), 'wb')
    pickle.dump(self.labels,bow_label_file)
    bow_label_file.close()
复制

最终得到了每篇文章的 bow 的向量,由于这块的代码是在我的笔记本电脑上运行的,直接跑占用内存太大,因为每一篇文章在 token set 中的表示是极其稀疏的,因此我们可以选择将其转为 csr 表示,然后进行模型训练,转为 csr 并保存中间结果代码如下:

def to_csr(self):
    self.bow = pickle.load(open(self.data_path.replace("all.csv","bow_vec.pl"), 'rb'))
    self.labels = pickle.load(open(self.data_path.replace("all.csv","bow_label.pl"), 'rb'))
    data = []
    rows = []
    cols = []
    line_count = 0
    for line in self.bow:        
for elem in line:
            rows.append(line_count)
            cols.append(elem[0])
            data.append(elem[1])
        line_count += 1
    print "dictionary shape ({0},{1})".format(line_count, self.dictionary.__len__())
    bow_sparse_matrix = csr_matrix((data,(rows,cols)), shape=[line_count, self.dictionary.__len__()])    
print "bow_sparse matrix shape: "
    print bow_sparse_matrix.shape    # rarray=np.random.random(size=line_count)
    self.train_set, self.test_set, self.train_tag, self.test_tag = train_test_split(bow_sparse_matrix, self.labels, test_size=0.2)    
print "train set shape: "
    print self.train_set.shape
    train_set_file = open(self.data_path.replace("all.csv","bow_train_set.pl"), 'wb')
    pickle.dump(self.train_set,train_set_file)
    train_tag_file = open(self.data_path.replace("all.csv","bow_train_tag.pl"), 'wb')
    pickle.dump(self.train_tag,train_tag_file)
    test_set_file = open(self.data_path.replace("all.csv","bow_test_set.pl"), 'wb')
    pickle.dump(self.test_set,test_set_file)
    test_tag_file = open(self.data_path.replace("all.csv","bow_test_tag.pl"), 'wb')
    pickle.dump(self.test_tag,test_tag_file)
复制

最后训练模型代码如下:

def train(self):
    print "Beigin to Train the model"
    lr_model = LogisticRegression()
    lr_model.fit(self.train_set, self.train_tag)    
print "End Now, and evalution the model with test dataset"
    # print "mean accuracy: {0}".format(lr_model.score(self.test_set, self.test_tag))
    y_pred = lr_model.predict(self.test_set)    
print classification_report(self.test_tag, y_pred)    
print confusion_matrix(self.test_tag, y_pred)    
print "save the trained model to lr_model.pl"
    joblib.dump(lr_model, self.data_path.replace("all.csv","bow_lr_model.pl"))
复制

TF-IDF

TF-IDF 和 Bow 的操作十分类似,只是在向量化使使用 tf-idf 的方法:

def vec(self):
    """ vec: get a vec representation of bow
    """
    self.__get_all_tokens()    
print "before filter, the tokens len: {0}".format(self.dictionary.__len__())
    vectorizer = CountVectorizer(min_df=1e-5)
    transformer = TfidfTransformer()    # sparse matrix
    self.tfidf = transformer.fit_transform(vectorizer.fit_transform(self.corpus))
    words = vectorizer.get_feature_names()    
print "word len: {0}".format(len(words))    # print self.tfidf[0]
    print "tfidf shape ({0},{1})".format(self.tfidf.shape[0], self.tfidf.shape[1])    
# write the tfidf vec into a file
    tfidf_vec_file = open(self.data_path.replace("all.csv","tfidf_vec.pl"), 'wb')
    pickle.dump(self.tfidf,tfidf_vec_file)
    tfidf_vec_file.close()
    tfidf_label_file = open(self.data_path.replace("all.csv","tfidf_label.pl"), 'wb')
    pickle.dump(self.labels,tfidf_label_file)
    tfidf_label_file.close()
复制

这两类方法效果都不错,都能达到 98+% 的准确率。

CNN

语料处理的方法和传统的差不多,分词之后,使用 pretrain 的 word2vec。在这里我遇到一个坑,我一开始对自己的分词太自信了,最后模型一直不能收敛,后来向我们组博士请教,极有可能是由于分词的词序列中很多在 pretrained word2vec 里面是不存在的,而我这部分直接丢弃了,所有可能存在问题,分词添加了词典。然后,我对于 pre-trained word2vec 不存在的词做了一个随机初始化,然后就能收敛了,学习了!

载入 word2vec 模型和构建 CNN 网络代码如下(增加了一些 bn 和 dropout 的手段):

def gen_embedding_matrix(self, load4file=True):
    """ gen_embedding_matrix: generate the embedding matrix    """
    if load4file:
        self.__get_all_tokens_v2()    
else:
        self.__get_all_tokens()    
print "before filter, the tokens len: {0}".format(
        self.dictionary.__len__())
    self.__filter_tokens()    
print "after filter, the tokens len: {0}".format(
        self.dictionary.__len__())
    self.sequence = []    
for file_token in self.corpus:
        temp_sequence = [x for x, y in self.dictionary.doc2bow(file_token)]        print temp_sequence
        self.sequence.append(temp_sequence)

    self.corpus_size = len(self.dictionary.token2id)
    self.embedding_matrix = np.zeros((self.corpus_size, EMBEDDING_DIM))    print "corpus size: {0}".format(len(self.dictionary.token2id))    for key, v in self.dictionary.token2id.items():
        key_vec = self.w2vec.get(key)        
if key_vec is not None:
            self.embedding_matrix[v] = key_vec        
else:
            self.embedding_matrix[v] = np.random.rand(EMBEDDING_DIM) - 0.5
    print "embedding_matrix len {0}".format(len(self.embedding_matrix))def __build_network(self):
    embedding_layer = Embedding(
        self.corpus_size,
        EMBEDDING_DIM,
        weights=[self.embedding_matrix],
        input_length=MAX_SEQUENCE_LENGTH,
        trainable=False)    # train a 1D convnet with global maxpooling
    sequence_input = Input(shape=(MAX_SEQUENCE_LENGTH, ), dtype='int32')
    embedded_sequences = embedding_layer(sequence_input)
    x = Convolution1D(128, 5)(embedded_sequences)
    x = BatchNormalization()(x)
    x = Activation('relu')(x)
    x = MaxPooling1D(5)(x)
    x = Convolution1D(128, 5)(x)
    x = BatchNormalization()(x)
    x = Activation('relu')(x)
    x = MaxPooling1D(5)(x)    
print "before 256", x.get_shape()
    x = Convolution1D(128, 5)(x)
    x = BatchNormalization()(x)
    x = Activation('relu')(x)
    x = MaxPooling1D(15)(x)
    x = Flatten()(x)

    x = Dense(128)(x)
    x = BatchNormalization()(x)
    x = Activation('relu')(x)
    x = Dropout(0.5)(x)    
print x.get_shape()
    preds = Dense(self.class_num, activation='softmax')(x)    
print preds.get_shape()
    adam = Adam(lr=0.0001)
    self.model = Model(sequence_input, preds)
    self.model.compile(
        loss='categorical_crossentropy', optimizer=adam, metrics=['acc'])
复制

另外一种网络结构,韩国人那篇文章,网络构造如下:

def __build_network(self):
    embedding_layer = Embedding(
        self.corpus_size,
        EMBEDDING_DIM,
        weights=[self.embedding_matrix],
        input_length=MAX_SEQUENCE_LENGTH,
        trainable=False)    # train a 1D convnet with global maxpooling
    sequence_input = Input(shape=(MAX_SEQUENCE_LENGTH, ), dtype='int32')
    embedded_sequences = embedding_layer(sequence_input)
    conv_blocks = []    for sz in self.filter_sizes:
        conv = Convolution1D(
            self.num_filters,
            sz,
            activation="relu",
            padding='valid',
            strides=1)(embedded_sequences)
        conv = MaxPooling1D(2)(conv)
        conv = Flatten()(conv)
        conv_blocks.append(conv)
    z = Merge(
        conv_blocks,
        mode='concat') if len(conv_blocks) > 1 else conv_blocks[0]
    z = Dropout(0.5)(z)
    z = Dense(self.hidden_dims, activation="relu")(z)
    preds = Dense(self.class_num, activation="softmax")(z)
    rmsprop = RMSprop(lr=0.001)
    self.model = Model(sequence_input, preds)
    self.model.compile(
        loss='categorical_crossentropy',
        optimizer=rmsprop,
        metrics=['acc'])
复制

LSTM

由于我们的任务是对文章进行分类,序列太长,直接接 LSTM 后直接爆内存,所以我在文章序列直接,接了两层 Conv1D+MaxPool1D 来提取维度较低的向量表示然后接入 LSTM。网络结构代码如下:

def __build_network(self):
    embedding_layer = Embedding(
        self.corpus_size,
        EMBEDDING_DIM,
        weights=[self.embedding_matrix],
        input_length=MAX_SEQUENCE_LENGTH,
        trainable=False)    # train a 1D convnet with global maxpooling
    sequence_input = Input(shape=(MAX_SEQUENCE_LENGTH, ), dtype='int32')
    embedded_sequences = embedding_layer(sequence_input)
    x = Convolution1D(
        self.num_filters, 5, activation="relu")(embedded_sequences)
    x = MaxPooling1D(5)(x)
    x = Convolution1D(self.num_filters, 5, activation="relu")(x)
    x = MaxPooling1D(5)(x)
    x = LSTM(64, dropout_W=0.2, dropout_U=0.2)(x)
    preds = Dense(self.class_num, activation='softmax')(x)    
print preds.get_shape()
    rmsprop = RMSprop(lr=0.01)
    self.model = Model(sequence_input, preds)
    self.model.compile(
        loss='categorical_crossentropy',
        optimizer=rmsprop,
        metrics=['acc'])
复制

CNN结果:

C-LSTM 结果:

整个实验的结果由于深度学习这部分都是在公司资源上运行的,没有真正意义上地去做一些 trick 来调参来提高性能,这里所有的代码的网络配置包括参数都仅供参考,更深地工作需要耗费更多的时间来做参数的优化。

PS: 这里发现了一个 keras 1.2.2 的 bug,在写回调函数 TensorBoard,当 histogram_freq=1 时,显卡占用明显增多,M40 的 24g 不够用,个人感觉应该是一个 bug,但是考虑到 1.2.2 而非 2.0,可能后面 2.0 都优化了。

所有的代码都在 github 上:tensorflow-101/nlp/text_classifier/scripts

总结和展望

在本文的实验效果中,虽然基于深度学习的方法和传统方法相比没有什么优势,可能原因有几个方面:

  • Pretrained Word2vec Model 并没有覆盖新闻中切分出来的词,而且比例还挺高,如果能用网络新闻语料训练出一个比较精准的 Pretrained Word2vec,效果应该会有很大的提升;
  • 可以增加模型训练收敛的 trick 以及优化器,看看是否有准确率的提升;
  • 网络模型参数到现在为止,没有做过深的优化。

UPDATE

长文分类

CNN 3 Split(3, 4, 5) model 0.97+ CNN+LSTM 0.94+ 能够接近 Bow 和 TF-IDF 的效果(0.98+,0.99+),相信可以有更多的小技巧调参,很有信息在这个任务上面打败它们。

短文分类,利用新闻标题判断新闻类别:

CNN 3 Split(1,2,3) model 0.92+, LSTM 0.94+,而 Bow 和 TF-IDF 只能 0.80+,在短文本分类上基于深度学习的 DeepNLP 整体性能遥遥领先,另外 LSTM 在短文本上感觉比 CNN 有效,即使是比较复杂的 3 Split 的 CNN 也达不到和 LSTM 相同的效果。

调参心得

  • 当使用 DL Embedding 层时,如 Word2vec 中若不存在该词时,请不要随意扔掉,可选择随机初始化,可以的话统计不存在词数,如果数量较大,需分析相关原因;
  • 切词的好坏在一定程度上影响模型性能,但是如果使用不同的工具性能影响更大,因此,在使用 pretrain word2vec 和后面训练数据时,请确保使用相同分词工具,这在我的 task 上提升至少 0.07+;
  • 大的语料上的生成的比较通用的 word2vec 模型,可能比较有效。但是当你想提升准确率时,如果数据量够的话,可以考虑自己训练 word2vec,很有效;
  • 当上面都差不多没问题的时候,如果想再提升下,可以打开 Embedding 的 trainable,有比较合理的解释,word2vec 的 weight 是一个无监督学习任务,根据词的共现算的,结合 task 来再更新往往会更有效;
  • 有 GPU 真爽,尤其是这个 task,好快。

相关知识