NLP实践——多层多分类项目NeuralNLP-NeuralClassifier

NLP实践——多层多分类项目NeuralNLP-NeuralClassifier

  • 1. 项目介绍
  • 2. 运行环境
  • 3. 项目目录
  • 4. 数据格式
  • 5. 官方使用方法
    • 5.0 config参数介绍
    • 5.1 训练
    • 5.2 评估
    • 5.3 预测
  • 6. 修改后实现的使用方法
    • 6.1 训练
    • 6.2 评估
    • 6.3 预测
  • 总结

1. 项目介绍

此项目是腾讯开发的一个多层多分类应用工具,支持的任务包括,文本分类中的二分类、多分类、多标签,以及层次多标签分类。支持的文本编码模型包括 FastText, TextCNN, TextRNN, RCNN, VDCNN等。这篇博客将介绍如何使用这个项目实现文本的多标签多分类任务。

项目地址:
https://github.com/Tencent/NeuralNLP-NeuralClassifier

2. 运行环境

按照惯例,先介绍一下运行环境,官方的说明非常简单,是非常容易满足的环境:

Python 3
PyTorch 0.4+
Numpy 1.14.3+

但其中需要注意的是,pytorch的版本问题,高版本的pytorch的rnn跟旧版本有所不同,如果报length cpu相关的问题,则来到torch环境目录下的torch\nn\utils\rnn.py,大概是244行,修改为:

_VF._pack_padded_sequence(input, lengths.cpu().batch_first)

3. 项目目录

为了帮助读者更好地理解项目,这里对项目的目录组织形式进行简单的介绍:

|--conf     # config文件存放目录
|--data    # 所有数据和schema存放目录
|--dataset  # 构建dataloader所需脚本
|--evaluate
|--model
    |--classification   # 项目中使用到的所有特征编码器
    |--attention.py
    |--embedding.py
    |-- ......  各模型通用的一些模块
|--predict.txt    # 执行预测生成的预测结果
|--checkpoint_dir_{}  # 训练过程中保存下来的权重文件目录
|--dict_{}              # 加载数据时产生的缓存文件目录
|--train.py            # 官方提供的训练脚本
|--eval.py            # 官方提供的评估脚本
|--predict.py        # 官方提供的预测脚本

其中checkpoint目录和dict目录一开始是没有的,需要训练之后才会创建。

4. 数据格式

数据样式很简单,逐行的json格式,包括四个字段,使用者需要按照如下的形式去组织数据:

{
     
    "doc_label":["Computer--MachineLearning--DeepLearning", "Neuro--ComputationalNeuro"],
    "doc_token": ["I", "love", "deep", "learning"],
    "doc_keyword": ["deep learning"],
    "doc_topic": ["AI", "Machine learning"]
}

"doc_keyword" and "doc_topic" are optional.
  1. "doc_label"就是这篇文档对应的所有标签构成的list,如果是单分类任务,list的长度为1,层次分类任务,各层之间用“–”进行分隔;

  2. "doc_token"是这篇文档对应的所有token,中文可以使用各种分词工具进行分词。

  3. “doc_keyword” 和"doc_topic"是在fasttext算法中提供额外的输入特征的,可以不提供,但是这两个字段必须要有,可以置为空。

有关数据格式转换和训练数据样例的问题,这篇博客不会涉及。

5. 官方使用方法

5.0 config参数介绍

这里只介绍一部分有必要修改的基础参数设置,具体每个模型的参数含义不进行说明,感兴趣的同学可以去具体的模型中了解。关于embedding和feature的参数,可以使用默认的,如果已经有了一个成型的模型想优化效果,再考虑去修改。

{
     
  "task_info":{
     
    "label_type": "multi_label",                 # 单标签还是多标签
    "hierarchical": false,                       # 是否是层次分类
    "hierar_taxonomy": "data/rcv1.taxonomy",     # 如果是层次分类,层次结构树的路径
    "hierar_penalty": 0.000001                   # 层次标签传递中的惩罚系数
  },
  "device": "cuda",                              # cuda还是cpu
  "model_name": "TextCNN",                       # 编码器模型的名称
  "checkpoint_dir": "checkpoint_dir_rcv1",       # 模型权重文件保存路径
  "model_dir": "trained_model_rcv1",             # 好像没有实际作用
  "data": {
     
    "train_json_files": [                    
      "data/rcv1_train.json"                     # 训练集路径
    ],
    "validate_json_files": [
      "data/rcv1_dev.json"
    ],
    "test_json_files": [
      "data/rcv1_test.json"
    ],
    "generate_dict_using_json_files": true,      
    "generate_dict_using_all_json_files": true,
    "generate_dict_using_pretrained_embedding": false,
    "generate_hierarchy_label": true,
    "dict_dir": "dict_rcv1",
    "num_worker": 4
  },
  "feature": {
     
    "feature_names": [
      "token"                                   # 使用token作为特征还是char作为特征
    ],
    "min_token_count": 2,                       
    "min_char_count": 2,
    "token_ngram": 0,
    "min_token_ngram_count": 0,
    "min_keyword_count": 0,
    "min_topic_count": 2,
    "max_token_dict_size": 1000000,
    "max_char_dict_size": 150000,
    "max_token_ngram_dict_size": 10000000,
    "max_keyword_dict_size": 100,
    "max_topic_dict_size": 100,
    "max_token_len": 256,
    "max_char_len": 1024,
    "max_char_len_per_token": 4,
    "token_pretrained_file": "",
    "keyword_pretrained_file": ""
  },
}

5.1 训练

python train.py conf/train.json

参数是config文件。层次分类的训练则把config文件换成conf/train.hierar.json。

5.2 评估

python eval.py conf/train.json

参数是config文件

5.3 预测

python predict.py conf/train.json data/predict.json 

第一个参数是训练时的config文件,第二个参数是需要预测的文本
预测时指定待预测的文本路径,预测结果会保存在项目路径下的predict.txt中。

6. 修改后实现的使用方法

从预测的过程就可以看出,这其中是有问题的,预测的文本文件的路径,必须要写在config中,也就是说我们在训练的时候需要把希望预测的文档一并作为输入,显然这样子是不利于形成稳定服务的。

通过阅读代码发现,项目要求这样做的原因,主要在于token2id,因为在形成token2id的map的时候,是读取train,dev,test三个数据集里的所有token来形成的,换言之,假如在训练的过程中没有给入test,那么对于一个没有出现在train和dev中的token,在token2id的map中不存在的,也就是它没有办法被转换为token_id,进而没有办法获取embedding。

那么就没有办法了吗,如果每次预测都需要重新训练一个新的模型,那岂不是很浪费?答案自然是否定的,模型已经训练好了的情况下,要想实现分类,其实就是想办法拿到logits。

所以说,要想实现给定任意一篇文档,在不进行额外的操作的情况下对其进行分类的既定功能,核心就在于如何构建出可以直接输入到模型forward中的输入特征。

要解决这个问题,其实也不难,最先想到的想法,就是在构建数据集example的时候,用bert之类的预训练模型的词表,对输入进行tokenize,这样就需要一个合适的vocab.txt,尤其是对于中文来说,如果是bert-base的词表,则会将输入全部划分为单个的字符,于是token-level就等同于char-level了。
值得注意的是,尽管输入的样本中并没有给doc_char,但实际上在数据读入与加载的时候,模型是计算了doc_char的,并且在config中可以选择是采用token特征还是char特征。

我没有采用bert的vocab来处理这个问题,而是进行了很简单的一步操作,由于我的train_set, valid_set,都是用jieba进行分词的,所以在处理test的时候仍然采用jieba。

1)对输入的文本进行jieba分词

2)对分词之后的每一个token进行判断,如果出现在token_to_id_map中了,那么无事发生,如果没有,则进入3

3)对新的token,划分为char,然后再对每个char进行判断,如果在map中,则取对应的id,如果不在,则直接舍弃这个char

英文情况下直接用空格切分token,也可以采用nltk等分词工具,如果不在map中,则直接舍弃整个token。

从这个原理考虑,当你的train_set和valid_set越大,在predict的时候出现OOV的token的可能性就越小,反之,当面临小样本学习的场景,则可能出现大量的OOV,使得模型的效果很差。

这样一来就可以确保输入的文本序列成功转换为input_ids的输入特征了。原理弄清楚了,接下来我们进入实践环节。

6.1 训练

首先import所有需要的模块,注意相对路径的问题。这一步对评估和预测同样适用:

import os
import shutil
import sys
import time
import numpy as np
import jieba

import torch
from torch.utils.data import DataLoader

# 注意这里的import都是在项目的根路径
import util
from config import Config
from dataset.classification_dataset import ClassificationDataset
from dataset.collator import ClassificationCollator
from dataset.collator import FastTextCollator
from dataset.collator import ClassificationType
from classification_evaluate import ClassificationEvaluator as cEvaluator
from model.classification.drnn import DRNN
from model.classification.fasttext import FastText
from model.classification.textcnn import TextCNN
from model.classification.textvdcnn import TextVDCNN
from model.classification.textrnn import TextRNN
from model.classification.textrcnn import TextRCNN
from model.classification.transformer import Transformer
from model.classification.dpcnn import DPCNN
from model.classification.attentive_convolution import AttentiveConvNet
from model.classification.region_embedding import RegionEmbedding
from model.classification.hmcn import HMCN
from model.loss import ClassificationLoss
from model.model_util import get_optimizer, get_hierar_relations
from util import ModeType
from train import get_data_loader, get_classification_model, load_checkpoint, save_checkpoint, ClassificationTrainer

然后创建一个模型:

config = Config(config_file='./conf/train.cnews.json')
model_name = config.model_name
dataset_name = "ClassificationDataset"
collate_name = "FastTextCollator" if model_name == "FastText" \
        else "ClassificationCollator"

print('Creating model...')
empty_dataset = globals()[dataset_name](config, [], mode="train")
model = get_classification_model(model_name, empty_dataset, config)

加载数据集:

# 在这里我修改了获取data_loader的方法
# 目的是把id_to_token_map和id_to_label_map返回出来
# 以用于预测过程
def get_data_loader(dataset_name, collate_name, conf):
    """Get data loader: Train, Validate, Test
    """
    train_dataset = globals()[dataset_name](
        conf, conf.data.train_json_files, generate_dict=True)
    collate_fn = globals()[collate_name](conf, len(train_dataset.label_map))

    train_data_loader = DataLoader(
        train_dataset, batch_size=conf.train.batch_size, shuffle=True,
        num_workers=conf.data.num_worker, collate_fn=collate_fn,
        pin_memory=True)

    validate_dataset = globals()[dataset_name](
        conf, conf.data.validate_json_files)
    validate_data_loader = DataLoader(
        validate_dataset, batch_size=conf.eval.batch_size, shuffle=False,
        num_workers=conf.data.num_worker, collate_fn=collate_fn,
        pin_memory=True)

    test_dataset = globals()[dataset_name](conf, conf.data.test_json_files)
    test_data_loader = DataLoader(
        test_dataset, batch_size=conf.eval.batch_size, shuffle=False,
        num_workers=conf.data.num_worker, collate_fn=collate_fn,
        pin_memory=True)

    return train_data_loader, validate_data_loader, test_data_loader, train_dataset.id_to_token_map, train_dataset.id_to_label_map


# 数据集越大,这一步越耗时
print('Generating DataLoader...')
train_data_loader, validate_data_loader, test_data_loader, id_to_token_map, id_to_label_map = \
        get_data_loader(dataset_name, collate_name, config)

构建trainer并训练:

loss_fn = globals()["ClassificationLoss"](
        label_size=len(empty_dataset.label_map), loss_type=config.train.loss_type)
logger = util.Logger(config)
optimizer = get_optimizer(config, model)
evaluator = cEvaluator(config.eval.dir)
trainer = globals()["ClassificationTrainer"](
empty_dataset.label_map, logger, evaluator, config, loss_fn)

print('Training...')
trainer.train(train_data_loader, model, optimizer, "Train", config.train.num_epochs)
print('Best model saved at {}'.format(config.checkpoint_dir + '/' + config.model_name + '_best'))

6.2 评估

评估过程与训练过程类似,首先需要构建模型、数据集和trainer。训练完之后的评估只需要执行:

trainer.eval(validate_data_loader, model, optimizer, "test", 1)

如果希望加载一个已经训练好的模型,则采用如下的方式加载权重:

model.load_state_dict(torch.load(config.checkpoint_dir + '/' + config.model_name + '_best')['state_dict'])

6.3 预测

预测的时候遇到一个突出的问题——不同的编码器模型要求的对应输入不同,例如基于CNN的模型不需要输入token_len,而基于RNN的模型则需要,要想在一个统一的框架下实现对所有模型的适配就需要设计一个策略了。

进入到每个编码器模型对应的forward的方法,可以很清楚的看到batch是如何被调用的。
例如在textrnn.py中:

    def forward(self, batch):
        if self.config.feature.feature_names[0] == "token":
            embedding = self.token_embedding(
                batch[cDataset.DOC_TOKEN].to(self.config.device))
            length = batch[cDataset.DOC_TOKEN_LEN].to(self.config.device)
        else:
            embedding = self.char_embedding(
                batch[cDataset.DOC_CHAR].to(self.config.device))
            length = batch[cDataset.DOC_CHAR_LEN].to(self.config.device)
        output, last_hidden = self.rnn(embedding, length)

可以看到batch先取了[cDataset.DOC_TOKEN],又取了[cDataset.DOC_TOKEN_LEN],并且每一项特征只被取出来1次。

于是我采用队列来构建batch,设计了这样的一个类来组织特征:

    class BatchForPred:
        """
        为了不改变原有模型代码,通过这个类使得输入tensor可以适配各类模型的forward方法
        这个类本质上是一个队列,队列中的元素遵循先进先出的原则
        """
        def __init__(self, items):
            """
            :param items: 需要返回出的内容, 每次调用pop第一项,不同的模型对应的内容不同
                1. transformers: token_tensor
                2. dpcnn: token_tensor
                3. textvdcnn: token_tensor
                4. textcnn: token_tensor
                5. textrnn: token_tensor, doc_token_len
                6. textrcnn: token_tensor, doc_token_len
                7. fasttext: token_tensor, token_offset, doc_token_len,暂不支持
                8. drnn: token_tensor, doc_token_len, doc_token_mask
                9. AttentiveConvNet: token_tensor, doc_token_len, doc_token_mask
            """
            self.items = items

        def __getitem__(self, idx):
            return self.items.pop(0)
        
        def __len__(self):
            return len(self.items)

我没有对所有的算法都进行适配,感兴趣的同学可以参考这个思路把它补全:

  • transformers
  • dpcnn
  • textvdcnn
  • textcnn
  • textrnn
  • textrcnn
  • drnn
  • fasttext
  • AttentiveConvNet

回到预测任务本身,希望设计一个类,实现一次实例化,多次调用预测的功能。
那么这个类应该具有的基本方法包括:

  1. 初始化构造方法
  2. 对输入的每一个text转化为token
  3. 调用方法call

写成之后完整的预测类如下:

class MyPredictor:
    """
    重写的预测方法
    无需将测试集传入训练的过程
    由于在训练时需要用到所有数据集构建词表,所以有可能输入的待分类文本中出现OOV的token
    对于OOV的token,在中文情况下将其拆解为char,如果char也OOV则略过
    在英文情况下直接略过
    TODO: 英文token是否考虑sub word
    ---------------
    ver: 2021-10-21
    by: changhongyu
    """
    class BatchForPred:
        """
        为了不改变原有模型代码,通过这个类使得输入tensor可以适配各类模型的forward方法
        这个类本质上是一个队列,队列中的元素遵循先进先出的原则
        """
        def __init__(self, items):
            """
            :param items: 需要返回出的内容, 每次调用pop第一项,不同的模型对应的内容不同
                1. transformers: token_tensor
                2. dpcnn: token_tensor
                3. textvdcnn: token_tensor
                4. textcnn: token_tensor
                5. textrnn: token_tensor, doc_token_len
                6. textrcnn: token_tensor, doc_token_len
                7. fasttext: token_tensor, token_offset, doc_token_len,暂不支持
                8. drnn: token_tensor, doc_token_len, doc_token_mask
                9. AttentiveConvNet: token_tensor, doc_token_len, doc_token_mask
            """
            self.items = items

        def __getitem__(self, idx):
            return self.items.pop(0)
        
        def __len__(self):
            return len(self.items)
    
    def __init__(self, id_to_token_map, id_to_label_map, model, language='en', is_multi=False, threshold=None, topk=5):
        """
        :param id_to_token_map: dict: 在训练时生成的token id到token的映射
        :param id_to_label_map: dict: 在训练时生成的类别id到类别的映射
        :param model: nn.Model: 用于预测的模型
        :param is_multi: bool: 是否是多标签分类
        :param threshold: float: 判定为某标签的阈值
        :param language: str: en or zh
        :param topk: int: 多分类情况下输出前多少个label
        """
        self.model = model
        self.id_to_token_map = id_to_token_map
        self.id_to_label_map = id_to_label_map
        self.token_to_id_map = {
     v: k for k, v in id_to_token_map.items()}
        self.language = language
        self.is_multi = is_multi
        if self.is_multi:
            assert threshold is not None, ValueError('threshold must be float within [0, 1]')
            self.threshold = threshold
            self.topk = topk
        
    def get_tokens(self, text):
        """
        输入文本,切分为token
        """
        tokens = []
        if self.language == 'en':
            cut_tokens = text.split()
        elif self.language == 'zh':
            cut_tokens = [tok for tok in jieba.cut(text)]
        else:
            raise Exception('Language must be either `en` or `zh`.')
            
        for tok in cut_tokens:
            if tok in self.token_to_id_map:
                tokens.append(tok)
            else:
                tokens += [char for char in tok if char in self.token_to_id_map]

        return tokens
    
    def __call__(self, text):
        """
        对一篇文档进行预测
        """
        tokens = self.get_tokens(text)
        token_ids = [self.token_to_id_map[tok] for tok in tokens]
        token_tensor = torch.tensor(token_ids).unsqueeze(0)
        batch_items = [token_tensor]
        
        if str(type(self.model)).split('.')[-1].lower()[:-2] in ['textrnn', 'textrcnn', 'drnn', 'attentiveconvnet']:
            token_len_tensor = torch.tensor(torch.where(token_tensor>0)[0].size()[0]).unsqueeze(0)
            # print(token_len_tensor)
            batch_items.append(token_len_tensor)
        if str(type(self.model)).split('.')[-1].lower()[:-2] in ['drnn', 'attentiveconvnet']:
            token_mask_tensor = torch.tensor(torch.where(token_tensor>0, 1., 0.)) #.unsqueeze(0)
            # print(token_mask_tensor)
            batch_items.append(token_mask_tensor)
        batch = self.BatchForPred(batch_items)
        # print(len(batch))
        
        with torch.no_grad():
            logits = model(batch)
            if not self.is_multi:
                pred_label_id = int(logits.argmax().cpu().numpy())
                pred_label = self.id_to_label_map[pred_label_id]
                return [pred_label]
            
            else:
                pred_label_ids = []
                pred_label_idx = np.argsort(-logits.cpu().numpy())
                pred_labels = []
                if not len(pred_label_idx):
                    return []
                
                if not hasattr(pred_label_idx[0], '__len__'):
                    # drnn算法少一层,为了保持一致,人为添加一层
                    pred_label_idx = [pred_label_idx]
                    logits = [logits]
                print('Top {} logits as follows:'.format(self.topk))
                
                for j in range(min(len(pred_label_idx[0]), self.topk)):
                    # 依次取最大的topk个logit
                    print(logits[0][pred_label_idx[0][j]])
                    if logits[0][pred_label_idx[0][j]] > self.threshold:
                        # print(pred_label_idx[0][j])
                        pred_labels.append(self.id_to_label_map[pred_label_idx[0][j]])
                        
                return pred_labels

有了这个类之后预测就变得十分简单了
实例化:

predictor = MyPredictor(id_to_token_map, id_to_label_map, model, is_multi=True, threshold=0.4, language='zh')

预测:

predictor(text)

总结

这篇文章对多层多分类开源项目进行了梳理,提出了如何进行输入任意文档直接进行分类的一个思路,但是并没有对所有模型都进行适配,很多细节上也有一些可以完善的地方,感兴趣的同学可以结合自己的想法进一步丰富,或者留言讨论。

如果你认为这篇文章对你有所帮助,麻烦点一个免费的赞,我们下期再见。

你可能感兴趣的:(自然语言处理,自然语言处理,分类,pytorch)