专栏首页叫我NLPerFastText,我要你何用?

FastText,我要你何用?

No.1

每日吐槽

年前,本邓用 TextCNN 做了一个对话情绪识别模型,效果还不错,响应速度也挺快。

不是说 BERT预训练模型 吊打传统模型吗,为啥不用 BERT 呢?

因为对话系统由多个模块构成,系统的响应时间得限定在 ms 级别以内。意图识别模型相对而言更重要,因而使用重量级选手:BERT,而情绪识别模型使用轻量级的 TextCNN,已经可以满足需要。

那么说到快,NLPer 首先想到的肯定是 FastText。那我的问题来了:TextCNN 训练和预测速度也很快,也用到了 n-gram 特征,作为深度文本分类器,理论上效果更好。那为啥还要用 FastText 做文本分类呢?

你会说:FastText 使用了 n-gram 特征,可以训练词向量,使得未登录词(OOV词)和低频词也可以得到向量表示。

是的,FastText 的词向量模型训练好后,用模型确实可以得到未登录词的词向量,可这无法用在文本分类中啊。因为在文本分类的操作中,不是用词向量模型直接得到词向量,而且先把词转化为ID,再用ID去词向量矩阵中获取词向量。所以未登录词还是无法表示。

你会说:FastText 用 n-gram 特征,可以提取很丰富的文本信息。

可是如果 n-gram 中的n增大,那么词汇表也会成倍增大,给内存造成极大压力,FastText 用到 tri-gram 已经了不起了吧。而 TextCNN 中运用 n-gram,不会增大词表,只需要设置几个大小不同的卷积核,如大小为2、3、4、5,n的值还可以设得更大。所以即便不考虑网络的深度,TextCNN 也可以提取更丰富的特征啊。

几番询问未果。

经查阅资料,我发现自己对 FastText 存在一些误解。

FastText 的n-gram 是字符级的n-gram,也就是subwords,比如单词 dog 的 bi-gram 是:"<d" , "do", "g>" 。这与textcnn的不一样。

英文的subwords是拆成字母,那么中文的怎么办?Facebook开源的FastText 库的源码中,对中文的处理是首先将中文转化为 unicode 编码,然后把 unicode 编码映射为哈希,再对哈希进行分段,形成中文的subwords。同时,词本身的embedding 也作为特征加入训练中。

FastText 适合在类别数和数据量非常大的场景下做文本分类,比如数万个类别。这种场景下,FastText 的准确率与深度分类器相当,而训练和预测的速度能快好几个数量级。

看来还是我见识太少,FastText 可谓简洁优雅。

No.2

内容预告

下面用 pytorch 来实现一个简单的 FasText ,做文本多分类。

完整内容分为三部分,本文只涉及第一部分:

  • 用 TorchText 进行文本预处理,以及生成 batch 迭代器;
  • 搭建 FastText 模型,训练模型,以及进行模型评估;
  • 用训练好的模型作预测,并封装成API接口。

获得多高的分类精度,并不是这次的目的,因为自己写的肯定干不过开源的库。这次的目的是用 pytorch 自带的文本处理库 TorchText,构建一条高效的文本预处理pipline。

本文关注的重点是:

  • 如何用 TorchText ,来简化文本预处理的流程?
  • n-gram 特征的提取是怎么做的?
  • 如何生成训练和评估所需的 batch 迭代器?

No.3

用torchtext进行文本预处理

文本预处理和模型训练部分的代码,目录结构如下:

├── data
│   ├── results
│   │   └── his.h5
│   ├── stopwords
│   │   └── 哈工大停用词表.txt
│   └── 题库
│       └── 高中_历史
│           ├── origin
│           │   ├── 古代史.csv
│           │   ├── 现代史.csv
│           │   └── 近代史.csv
│           └── proc
│               ├── test.csv
│               ├── train.csv
│               └── valid.csv
└── fasttext
    ├── config.py
    ├── data_loader.py
    ├── fasttext_model.py
    ├── fasttext_train_helper.py
    └── fasttext_train.py

这次要做的是一个高中历史试题的分类任务,也就是训练一个分类器,对古代史、近代史和现代史进行分类。

其中古代史的部分样本如下:

0    [题目]\n据《左传》记载,春秋后期鲁国大夫季孙氏的家臣阳虎独掌权柄后,标榜要替鲁国国君整肃...
1    [题目]\n秦始皇统一六国后创制了一套御玺。如任命国家官员,则封印“皇帝之玺”;若任命四夷的...
2    [题目]\n北宋加强中央集权的主要措施有(   )\n①把主要将领的兵权收归中央②派文官担任...
3    [题目]\n商朝人崇信各种鬼神,把占卜、祭祀作为与神灵沟通的手段,负责通神事务的是商王和巫师...
4    [题目]\n公元963年,北宋政府在江淮地区设置了包括盐业管理,以及控制对茶叶销售的专卖等为...
Name: item, dtype: object

ok,开始。

【1:写一个参数配置的模块 config.py】

这个模块对一些参数进行配置:

  • 数据集和模型保存的路径;
  • GPU的使用;
  • 最大特征数、词表大小、batch size、<pad>的id,学习率、梯度裁剪阈值和 early stop 的最大步数等。

需要特别注意的是 最大特征数 和 词表大小 的关系:self.vocab_size = self.max_features + 2。

最大特征数是指,用词频矩阵或 n-gram 进行词表征时,只保留前 topk 个频率最高的词或 n-gram,这里我们取30000。而词表中,我们还需要加入<unk>和<pad>,用于标记未登录词和进行填补操作。

如果这里把二者等同起来,那输入模型时会报错。

#coding:utf-8
import numpy as np
import os,pathlib
import torch

"""根目录"""
root = pathlib.Path(os.path.abspath(__file__)).parent.parent

class Config(object):
    def __init__(self):
        self.his_dir_origin = os.path.join(root,"data","题库/高中_历史/origin")
        self.his_dir_proc = os.path.join(root,"data","题库/高中_历史/proc")
        self.class_map = {"现代史":0, "近代史":1, "古代史":2}
        self.stopwords_path = os.path.join(root,"data","stopwords/哈工大停用词表.txt")
        self.save_dir = os.path.join(root,"data","results")
        self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

        self.max_features = 30000
        self.vocab_size = 30002
        self.batch_size = 32
        self.pad_idx = 1
        self.embed_dim = 300
        self.output_dim = 3
        self.dropout = 0.5
        self.learning_rate = 1e-4
        self.num_epochs = 50
        self.max_grad_norm = 2.0
        self.gamma = 0.9
        self.require_improve = 1000

【2:写一个data_loader.py,用于文本预处理】

首先导入TorchText 等包:

#coding:utf-8
import torch
from torchtext import data
import os,re,jieba
import pandas as pd
import numpy as np
from sklearn.model_selection import train_test_split
from config import Config
import matplotlib.pyplot as plt

定义用于文本清洗、去除停用词和分词的函数。

"""加载哈工大停用词"""
def load_stop_words(stop_word_path):
    file = open(stop_word_path, 'r', encoding='utf-8')
    stop_words = file.readlines()
    stop_words = [stop_word.strip() for stop_word in stop_words]
    return stop_words

"""进行文本清洗,并用jieba分词"""
def clean_sentence(line):
    line = re.sub(
            "[a-zA-Z0-9]|[\s+\-\|\!\/\[\]\{\}_,.$%^*(+\"\')]+|[::+——()?【】《》“”!,。?、~@#¥%……&*()]+|题目", '',line)
    words = jieba.cut(line, cut_all=False)
    return words

"""文本清洗,分词和去除停用词"""
def sentence_proc(sentence):
    words = clean_sentence(sentence)
    words = [word for word in words if word not in stop_words]
    return ' '.join(words)

然后定义一个 build_dataset 函数,用于给样本贴标签、清洗文本、分词、划分数据集和保存处理好的数据。

数据集不能只分为训练集、验证集,还需要分测试集。因为应用 early stop 时,我们会保存在验证集上表现最好的模型,用这个模型在验证集上的准确率或F1-score来评估性能,是不准确的。应该评估模型在未见过的数据,也就是测试集上的表现。

再保存处理完毕的数据,这是因为 TorchText 需要直接导入文本数据文件,进行处理。

def build_dataset(config):

    """ 读取数据 """
    print("\nLoading the dataset ... \n")
    ancient_his_df = pd.read_csv(os.path.join(config.his_dir_origin,'古代史.csv'))
    contemporary_his_df = pd.read_csv(os.path.join(config.his_dir_origin,'现代史.csv'))
    modern_his_df = pd.read_csv(os.path.join(config.his_dir_origin,'近代史.csv'))

    """ 贴标签 """
    print("\nLabeling the dataset ... \n")
    ancient_his_df['label'] = '古代史'
    contemporary_his_df['label'] = '现代史'
    modern_his_df['label'] = '近代史'

    """ 数据清洗和分词 """
    print("\nCleaning text and segmenting ... \n")
    ancient_his_df['item'] = ancient_his_df['item'].apply(sentence_proc)
    contemporary_his_df['item'] = contemporary_his_df['item'].apply(sentence_proc)
    modern_his_df['item'] = modern_his_df['item'].apply(sentence_proc)

    """ 划分数据集并保存 """
    print("\nMerging and spliting dataset ... \n")
    dataset_df = pd.concat([ancient_his_df,contemporary_his_df,modern_his_df],axis=0,sort=True)
    print(f"\nThe shape of the dataset : {dataset_df.shape}\n")

    train_data, test_data = train_test_split(dataset_df[['item','label']],test_size=0.15)
    train_data, valid_data = train_test_split(train_data, test_size=0.15)

    train_data.to_csv(os.path.join(config.his_dir_proc,'train.csv'),header=True, index=False)
    valid_data.to_csv(os.path.join(config.his_dir_proc,'valid.csv'),header=True, index=False)
    test_data.to_csv(os.path.join(config.his_dir_proc,'test.csv'),header=True, index=False)
if __name__ == "__main__":

    config = Config()

    """ 数据预处理并保存 """
    stop_words = load_stop_words(config.stopwords_path)
    build_dataset(config)

【3:创建词表,生成batch迭代器】

下面这个函数可以很方便地把一个句子,拆分为n-gram。

def gene_ngram(sentence,n=3,m=1):
    """
    ----------
    sentence: 分词后的句子
    n: 取3,则为3-gram
    m: 取1,则保留1-gram
    ----------
    """
    if len(sentence) < n:
        n = len(sentence)
    list_ngram=[sentence[i - k:i] for k in range(m, n + 1) for i in range(k, len(sentence) + 1) ]
    ngram = [' '.join(i) for i in list_ngram]
    return ngram

句子 “秦始皇统一了六国” 的tri-gram为:

['秦始皇', '统一', '了', '六国', '秦始皇 统一', '统一 了', '了 六国', '秦始皇 统一 了', '统一 了 六国']

接下来创建词表和生成 batch 迭代器。

首先用 TorchText.data 定义一个 Field 对象,用于处理样本。参数:

  • tokenize:由于在文本预处理部分,已经进行了分词,整理成了 “秦始皇 统一 了 六国” 这种格式,所以只需要按空格分开。
  • preprocessing:对于按空格拆分好的句子,生成n-gram特征。
  • include_lengths:这个参数在 RNN 的模型中才会用到,用于记录每条句子的长度,所以设为False。

定义一个 LabelField 对象,用于处理标签。

定义一个 fields,"item" 和 "label" 分别为csv格式数据集中,文本列和标签列的列名。将列名和 Field 对象对应起来。

然后把保存好的训练集、验证集和测试集读取进来,数据为csv格式。参数:

  • path:数据集所在的目录
  • format:数据格式,支持json,tsv和csv。
  • header:csv格式的数据有两列,列名为 "label"和"item",读取时要去掉这俩列名。

接着用训练集创建词表,保留词频最高的前30000个n-gram。在创建词表的过程中,会加入 <unk> 和 <pad> 这两个标记,所以实际的 vocab size 为30002,也就是模型输入时的 input size。

得到的词表是这样的一个字典:{"<unk>": 0, "<pad>":1, "答案":2, ... }

通过 TEXT.unk_token,TEXT.pad_token,可以得到 <unk>和<pad> 在词表中的ID。

最后生成 batch 迭代器,用的是 data.BucketIterator.splits 这个函数。这个函数非常强大,它会首先把样本按长度进行排序,分batch时,长度相近的样本,分到同一个batch,减少 zero pad 的数量,从而加快计算的速度。

参数:

  • batch_sizes:注意由于同时传入了训练集、验证集和测试集,那么这三个数据集的 batch size 要分别指定。这里容易把关键词参数写成 batch_size,这是传入一个数据集时用的。
  • sort_key:对样本进行排序的依据。这里是按长度排序。
  • device:指定计算的GPU。

这样就得到了batch迭代器。

def batch_generator(config):
    """ 定义Field对象 """
    print("\nDefining the Field ... \n")
    tokenizer = lambda x: x.split()
    TEXT = data.Field(sequential      = True, 
                      tokenize        = tokenizer,
                      preprocessing   = gene_ngram,
                      include_lengths = False)

    LABEL = data.LabelField(sequential=False, dtype=torch.int64)
    fields = [('item', TEXT),('label', LABEL)]

    """ 加载CSV数据,建立词汇表 """
    print("\nBuilding the vocabulary ... \n")
    train_data, valid_data, test_data = data.TabularDataset.splits(
                                          path        = config.his_dir_proc,
                                          train       = 'train.csv',
                                          validation  = 'valid.csv',
                                          test        = 'test.csv',
                                          format      = 'csv',
                                          fields      = fields,
                                          skip_header = True) 

    """ 千万注意,创建成功后 ,vocab size 为 max features 加 2
    而embedding层的input size 为 vocab size。"""
    TEXT.build_vocab(train_data,
                     max_size=config.max_features)
    LABEL.build_vocab(train_data)
    print(f"\nUnique tokens in TEXT vocabulary: {len(TEXT.vocab)}\n")
    print(f"\nUnique tokens in LABEL vocabulary: {len(LABEL.vocab)}\n") 
    print(LABEL.vocab.stoi)
    print(f"\nInput size or vocab size is {len(TEXT.vocab)}\n")
    print(f"\nPad index is {TEXT.vocab.stoi[TEXT.pad_token]}")

    """ 生成batch """
    print("\nCreating the batch ... \n")
    train_iter, valid_iter, test_iter = data.BucketIterator.splits(
                                          (train_data, valid_data, test_data), 
                                          batch_sizes = (config.batch_size,) * 3,
                                          # 这里注意加一行,而且 item 是之前在Field里面定义好的 
                                          sort_key = lambda x: len(x.item),
                                          sort_within_batch=False,
                                          device=config.device)

    return train_iter, valid_iter, test_iter

我们测试一下这个函数,发现输入模型的数据的格式为 [sequence length, batch size],这与tensorflow 2.0 的习惯不一样—— tf2 默认 batch size 在第0维,序列长度在第1维。

if __name__ == "__main__":
    config = Config()
    """ 生成batch迭代器 """
    train_iter, valid_iter, test_iter = batch_generator(config)
    for x_batch, y_batch in train_iter:
        print(f"The shape of item batch is {x_batch.shape}")
        print(f"The shape of label batch is {y_batch.shape}")
    The shape of item batch is torch.Size([573, 64])
    The shape of label batch is torch.Size([64])

【4:观察样本是否存在类别不平衡问题】

如果存在样本的类别不平衡问题,那么有两方面需要考虑:

  • 用准确率作为评估指标是不合适的,用 F1-score 或 AUC 更合适;
  • 需要缓解类别不平衡的问题。上采样和下采样会改变训练集,最简单的方法还是代价敏感学习。
  • 代价敏感学习,具体就是计算加权的交叉熵损失,对于数量多的类别,给较低的权重,反之给较高的权重,让模型关注数量更少的类别。因此需要计算class weights。

我们首先把各类别的频率画出来,label的对应关系为:{"现代史":0, "近代史":1, "古代史":2}。从下面的图中可以看到,存在不平衡问题,但是不严重。

然后计算个类别的权重,公式如代码所示。p_class为各类别的频率。

class_weights = 1 / np.log(1.01 + p_class)

得到 class weights 为:

[2.5426, 3.4088, 5.3102]

在训练时,这个 class weights 可以传入,或者不传,对比一下效果。在用TextCNN 训练情绪识别模型时,数据集不平衡,可是发现传入 class weights后,模型收敛的速度变慢了,而且效果比不传入差。没搞明白咋回事。

""" 观察是否存在样本不平衡问题 """
def visualize_freqs(freqs):
    plt.bar(range(3),freqs,width=0.5,color=["r","b","y"],label="history")
    plt.xticks(range(3),[0,1,2])
    plt.title("The frequencies of three classes")
    plt.lengend()
    plt.show()

def calcu_class_weights(config):

    """ 读取标签数据并转化为数字(非one-hot)"""
    train_data = pd.read_csv(os.path.join(config.his_dir_proc,"train.csv"))
    labels = train_data["label"].map(config.class_map)
    labels = np.array(labels.tolist(),dtype=np.int32)

    """ 计算class weights """
    freqs = np.bincount(labels)

    """ 作图观察类别不平衡情况 """
    visualize_freqs(freqs)

    p_class = freqs / len(labels)
    class_weights = 1 / np.log(1.01 + p_class)

    class_weights = torch.FloatTensor(class_weights).to(config.device)
    return class_weights

好的,本文的内容就这些了。我们已经把数据预处理好了,在训练时可以直接输入。

本文分享自微信公众号 - 叫我NLPer(gh_39af6a29685b),作者:邓邓

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

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

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

我来说两句

0 条评论
登录 后参与评论

相关文章

  • 抽取式摘要:TextRank和BertSum。

    文本摘要,按摘要输出的类型,可以分为抽取式摘要(Extractive)和生成式摘要(Abstractive)。

    邓邓最棒
  • FastText,我要你何用?(中)

    这次疫情来势凶猛,传播范围之广,持续时间之久,对生活和生产的影响之大,让我始料未及。

    邓邓最棒
  • 主成分分析法:NLPer的断舍离(上篇)

    矩阵分解是推荐算法的一个重要分支,把用户-商品大矩阵,分解为用户偏好和商品属性两个小矩阵,其实也就是一种断舍离。

    邓邓最棒
  • 揭开UVM configure机制的神秘面纱

    UVM中的configure机制用来将一些对象(objects)和数据(data)传递到验证平台中的各种组件。

    AsicWonder
  • GitHub重大更新:在线开发上线,是时候卸载IDE了

    在今年 GitHub 的第一个虚拟会议——Satellite 上,GitHub 发布了由 Visual Studio 提供技术支持的在线 IDE 工具——Cod...

    机器之心
  • SAP最佳业务实践:ETO–报价处理(232)-6项目计划

    CJ2B项目计划 已经使用模板(包含进一步处理所需的全部设置)创建项目。现在,必须根据询价中的需求进行计划。同时,还有些调整也必须在此完成,即更改缺省工作,为活...

    SAP最佳业务实践
  • 昨天GitHub迎来重大更新

    在今年 GitHub 的第一个虚拟会议——Satellite 上,GitHub 发布了由 Visual Studio 提供技术支持的在线 IDE 工具——Cod...

    Nealyang
  • GitHub重大更新:在线开发上线,以后可能就不需要IDE了

    在今年 GitHub 的第一个虚拟会议——Satellite 上,GitHub 发布了由 Visual Studio 提供技术支持的在线 IDE 工具——Cod...

    CV君
  • 卷积神经网络学习笔记

    1.卷积神经网络的图像识别原理: 通过过滤函数 来描绘出图像的边界: 过滤函数和图像相同区域的数值进行相乘,得到新的图像, 新图像则只剩下边图像。 cros...

    企鹅号小编
  • 阻止iOS Web APP中点击链接跳转到Safari 浏览器新标签页

    最近为了更好地接触移动Web 开发狠心购买了一台ipad mini(之前一直都是借同学的,借多了就不好意思了)。拿来调试DeveMobile 与EaseMobi...

    Jeff

扫码关注云+社区

领取腾讯云代金券