基于博客标签的多标签分类器(multi-label classification)

一、写在前面的话

最近项目需要做一个对问题的打标签系统,这里的问题就是csdn问答板块里面用户提出的问题,打上统一标签之后有利于问题的归类。目前领导的想法是对csdn的资源,例如,博客、问答等打上统一的标签,之后利用整合的资源做进一步的应用。

统一标签目前大概有400-500个,有大类和小类两个层级,对于python这个大类来说,下面的小类有:python,list,django,virtualenv,tornado,flask等标签。大家都知道每个博客的标签数量是不固定的,有可能是1个也有可能是多个,所以这里是一个多标签分类的场景。

或许是博客的数据是现成的,阴差阳错之下就先使用博客的数据做了一个多标签分类器进行了验证。模型也比较简单,下面直接开始介绍吧。

二、模型部分

2.1 框架选择

工业界的话选择tensorflow,开源,其生态比较完善,不论模型的互转,还是推理引擎的支持,其都有相应的项目支持,并且tensorflow2.0也开始支持动态图,习惯用pytorch做模型的小伙伴也不妨尝试一下。

2.2 模型搭建

这里选择在textcnn的基础之上进行改进,不熟悉textcnn的可以先自行百度一下,至于为什么选择textcnn,也是因为在众多可选的分类器中,textcnn应该是性价比最高的一款了,一般来说,其效果好于机器学习分类算法,例如svm,但是比bert等预训练模型又差一些。同时考虑到工程应用要考虑到推理速度,硬件成本等,textcnn就成了首选。

首先来看一下textcnn的基本结构,这里借用论本A Sensitivity Analysis of (and Practitioners’ Guide to) Convolutional Neural Networks for Sentence Classification的结构图来进行说明:

基于博客标签的多标签分类器(multi-label classification)

这个图其实已经非常好理解了,前面的几层就不多说,直接看改造部分,原本的textcnn在全连接层之后经过softmax就可以得到每个类别的概率,取概率最高的一个的话就是一个多分类,现在让全连接层输出num_class*2,然后reshape成batch_size*num_class*2,即让每个类别单独做一个二分类,计算每个二分类loss。最后两层改造之后的结构为:

基于博客标签的多标签分类器(multi-label classification)

 

这里直接使用tensorflow2的动态图(sub-class model)尝试搭建模型,配置如下:

class BlogTagClassifyConfig(object):
    # Data loading params
    file_train_set = "./data/pro/datasets/tags/blog/recommend/train.txt"
    file_dev_set = "./data/pro/datasets/tags/blog/recommend/dev.txt"
    out_dir = "./data/pro/models/tag/"

    # Model Hyperparameters
    vocab_size = 0
    embedding_dim = 300
    dropout_rate = 0.6
    num_classes = 50
    regularizers_lambda = 0.2
    filter_sizes = "2,3,4"
    num_filters = 128
    seq_length = 120
    learning_rate = 3e-4
    # Training parameters
    batch_size = 256
    num_epochs = 100
    evaluate_every = 100
    print_every = 50
    # 阈值
    threshold = 0.8

模型:

#!/usr/bin/env python
# -*- encoding: utf-8 -*-
'''
@Time    :   2021/07/07
@Author  :   clong
@Descript:   博客标签分类模型
'''

import tensorflow as tf
from tensorflow import keras

class TextCNN(tf.keras.Model):
    """
    TextCNN模型
    """
    def __init__(self, config):
        super(TextCNN, self).__init__()
        self.config = config
        self.embedding = tf.keras.layers.Embedding(self.config.vocab_size+1, 
                                                    self.config.embedding_dim,
                                                    mask_zero=True,
                                                    input_length=self.config.seq_length,
                                                    name='embedding')
        self.add_channel = tf.keras.layers.Reshape((self.config.seq_length, self.config.embedding_dim, 1), name='add_channel')
        self.conv_pool = self.build_conv_pool()

        self.dropout = tf.keras.layers.Dropout(self.config.dropout_rate, name='dropout')
        self.dense = tf.keras.layers.Dense(self.config.num_classes*2,
                                            kernel_regularizer=tf.keras.regularizers.l2(self.config.regularizers_lambda),
                                            bias_regularizer=tf.keras.regularizers.l2(self.config.regularizers_lambda),
                                            name='dense')
        self.flatten = tf.keras.layers.Flatten(data_format='channels_last', name='flatten')
        self.reshape = tf.keras.layers.Reshape((self.config.num_classes, 2), name='reshape')

        self.optimizer = tf.keras.optimizers.Adam(learning_rate=self.config.learning_rate)


    def build_conv_pool(self):
        def conv_pool(embed):
            pool_outputs = []
            for filter_size in list(map(int, self.config.filter_sizes.split(','))):
                filter_shape = (self.config.filter_size, self.config.embedding_dim)
                conv = keras.layers.Conv2D(self.config.num_filters, filter_shape, strides=(1, 1), padding='valid',
                                        data_format='channels_last', activation='relu',
                                        kernel_initializer='glorot_normal',
                                        bias_initializer=keras.initializers.constant(0.1),
                                        name='convolution_{:d}'.format(filter_size))(embed)
                max_pool_shape = (self.config.seq_length - filter_size + 1, 1)
                pool = keras.layers.MaxPool2D(pool_size=max_pool_shape,
                                            strides=(1, 1), padding='valid',
                                            data_format='channels_last',
                                            name='max_pooling_{:d}'.format(filter_size))(conv)
                pool_outputs.append(pool)
            return pool_outputs
    
    return conv_pool
    
    @tf.function
    def call(self, x, training=None):
        x = self.embedding(x)
        x = self.add_channel(x)
        x = self.conv_pool(x)

        x = tf.keras.layers.concatenate(x, axis=-1, name='concatenate')
        x = self.flatten(x)
        x = self.dropout(x, training)
        x = self.dense(x)
        x = self.reshape(x)
        x = tf.nn.softmax(x, axis=-1, name="softmax")
        return x

 三、数据部分

3.1 数据获取

数据采用博客数据,由于数据的不平衡性,现在取50个类别进行验证,每个类别取2000条数据。

3.2 词典构造

如果有领域词典的话,最好将领域词典和标签加入分词自定义词典,对分词后的博客数据统计词频,按照词频进行排序,这里取前面20000个词。为了防止漏掉标签词特征,可以将标签也加入到词典中。

感觉这里也可以采用两外两种方式进行尝试:分字,采用字符级的词典,这样词典就会比较小,但是可能训练收敛的速度就会慢一些。另外一种就是特征选择算法挑选特征构建词典,例如卡方验证,信息增益等等。

对于词典中未登录的词(unknown words),网上一般做法都是在词典添加[UNK]词或者随机选择一个未知词,但是这里只是一个分类模型,也不会设计太多的语义,直接去掉未登录的词,简化处理。

四、写在最后

多标签分类器其实还要涉及到分类结果的评估,也可以使用编辑距离等来计算相似度,但我这里更加重视模型打上标签的情况,故没采用通用的方法进行评估。

从结果来看,打标签的效果还是可以的,高阈值的标签都是有着很高的相关性。一下步构造问答的训练语料进行尝试。

上一篇:2021金三银四面试季!java字符串比较


下一篇:Java多线程 - 线程池的七大参数?手写一个线程池?