伴随着bert、transformer模型的提出,文本预训练模型应用于各项NLP任务。文本分类任务是最基础的NLP任务,本文回顾最先采用CNN用于文本分类之一的textcnn模型,意在巩固分词、词向量生成、任务词表构建、预训练向量加载、深度学习网络构建、模型训练、验证、预测等NLP模型构建的基本流程。本文中textcnn网络的构建和训练基于pytorch框架。明确以上流程,能够快速打牢基础,自然理解衔接应用后续类bert模型,用于NLP的各项任务(如文本分类、序列标注、文本生成、半指针半标注的分类预测任务等)。
构建NLP任务的基本思路和流程如下:
(1)构建预训练的词向量模型,也可以直接使用已经训练好的预训练模型。构建当前任务下的词典和词典与预训练向量的映射。
(2)构建NLP任务数据集的转换,转换成深度学习框架所支持的格式,本文采用pytorch框架,故介绍数据集转换成pytorch框架支持的格式,用于训练、验证与预测。
(3)简介textcnn的原理,并采用pytorch构建网络结构。
(4)构建模型的训练、评估与预测流程。
由于笔者在自己学习的过程中,在第一次全流程手或者看人家的分享时不希望一篇博客写的过长,故将此任务分为上、下两篇博客。
word2vec提出后,文本中词的表征采用稠密的分布式向量表征形式。因此,需要大语料下训练词向量模型,当然也可以加载别人已经训练好的模型。
一般来说,预训练的词表更考虑通用性,当然可以针对领域构建。然而,实际在具体NLP任务中并不会用到这么多词,会统计NLP任务数据集的特点,加载任务需要关注的词就足够了。比如在本文中,我们采用数据集中词频出现次数最多的30000个词,来代表这个NLP任务数据集中包含场景语义的有效词。注意这是一种简化的做法,实际采用哪些词根据场景和数据集特点决定。
本文采用词向量作为输入,是一种word-based的方法,就是把句子先分词,然后把词映射成为向量,当然后续bert模型中文场景一般分每个字进行映射,只是分词模型和词向量构建不同,整体模型构建流程还是基本一样的。
词向量模型的训练一般会搜集大量的文本,这些文本可以是通用文本也可以是与NLP任务相关的领域文本。然后进行分词,接着训练word2vec或者glove模型,这样训练出来的模型会有更好的泛化性。下文中简介了,基于百度提出的Lac模型进行分词,采用gensim中的word2vec构建词向量模型:
基于LAC分词与gensim的词向量训练,pandas批量中文分词_chen10314的博客-CSDN博客
将训练集中的句子进行分词,统计词频前30000的词作为当前NLP任务的有效词。
from collections import Counter
from tqdm import tqdm
def get_cutwords_list(line):
sen = lac.run(line) #采用lac进行分词,返回list
return sen
def get_vocab(config):
model_train = pd.read_csv(config['train_file_path'])
model_train['cut'] = ''
cut_df(model_train[['sentence']], get_cutwords_list)
token_counter = Counter()
for i in tqdm(model_train['cut'], total=len(model_train['cut']), desc='Counting token'):
token_counter.update(i)
vocab = set(token for token, _ in token_counter.most_common(config['vocab_size']))
return vocab
vocab = get_vocab(config)
在我们统计高频的3W个词中,找寻预训练词表中有的词,构建分词后的token与预训练词表embedding之间的映射。
在token2embedding的映射过程中,有几个特别的字符需要处理:
(1)填充位: pad 深度学习训练的输入是固定的,但是句子长度是不固定的,因此一般会预设一个句子的最大长度,如果句子没有满最大长度,没满的位置由pad填充。
(2)未登录词:unk 分词的token结果,不在所构建预训练词表中,一般会专门设置一个token处理
(3)句子开头和结尾:bos、eos 句子的开头结尾,bert之前手工构建模型的时代也不一定需要,为了和后续bert模型中cls、sep形成对应,此处先提及一下。
from gensim.models import KeyedVectors
def get_embedding(vocab):
token2embedding ={}
word2vec = KeyedVectors.load('w2v/model/allw2v.model')
for token in vocab:
if token in word2vec.wv.key_to_index.keys():
token2embedding[token] = word2vec.wv[token]
meta_info = word2vec.wv[0].shape[0]
token2id = {token: idx for idx, token in enumerate(token2embedding.keys(), 4)}
id2embedding = {token2id[token]: embedding for token, embedding in token2embedding.items()}
PAD, UNK, BOS, EOS = '', '', '', ''
token2id[PAD] = 0
token2id[UNK] = 1
token2id[BOS] = 2
token2id[EOS] = 3
id2embedding[0] = [.0] * int(meta_info)
id2embedding[1] = [.0] * int(meta_info)
id2embedding[2] = np.random.random(int(meta_info)).tolist()
id2embedding[3] = np.random.random(int(meta_info)).tolist()
emb_mat = [id2embedding[idx] for idx in range(len(id2embedding))]
return torch.tensor(emb_mat, dtype=torch.float), token2id, len(vocab)+4
此处将token2embedding,拆分成了token2id、id2embedding两步。句子分词成token后先转换成id,将unk的情况先处理了。然后,id2embedding就相当于一个预训练的词表,转换成tensor,通过nn.Embedding.from_pretrained(emb_mat, freeze=True)加载入模型中。
我们将一些文件路径、词表路径、随机数等变量写成在配置config里面,便于修改。
config = {
'train_file_path': 'dataset/train.csv',
'test_file_path': 'dataset/test.csv',
'train_val_ratio': 0.1, # 10%用作验证集
'vocab_size': 30000, # 词典 3W
'batch_size': 64, # batch 大小 64
'num_epochs': 2, # 2次迭代
'learning_rate': 1e-3, # 学习率
'logging_step': 300, # 每跑300个batch记录一次
'seed': 2021 # 随机种子
}
config['device'] = 'cuda' if torch.cuda.is_available() else 'cpu' # cpu&gpu
import random
import numpy as np
def seed_everything(seed):
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
torch.cuda.manual_seed_all(seed)
return seed
seed_everything(config['seed'])
构建好token2id、id2embedding的操作后,需要将训练集(训练集)、测试集转换成id,并通过pytorch内置的数据集加载方法,批量加载训练数据用于模型训练。
对数据集进行分词,并映射为id,分词依旧采用百度lac分词器。
def tokenizer(sent, token2id):
ids = [token2id.get(token, 1) for token in lac.run(sent)]
return ids
在训练模式中,需要划分训练集与验证集,并转换成id,验证集用于在训练过程中评估模型的训练情况,看看模型是否拟合数据,是否会产生过拟合的情况等。由于上文已经尝试了pandas批处理方式,此处采用逐行分词,练练基本操作。其中,文件row[1]指数据集标签的label id列,row[-1]指带分词的句子列。
import pandas as pd
from collections import defaultdict
def read_data(config, token2id, mode='train'):
data_df = pd.read_csv(config[f'{mode}_file_path'], sep=',')
if mode == 'train':
X_train, y_train = defaultdict(list), []
X_val, y_val = defaultdict(list), []
num_val = int(config['train_val_ratio'] * len(data_df))
else:
X_test, y_test = defaultdict(list), []
for i, row in tqdm(data_df.iterrows(), desc=f'Preprocesing {mode} data', total=len(data_df)):
label=row[1] if mode == 'train' else 0
sentence = row[-1]
inputs = tokenizer(sentence, token2id)
if mode == 'train':
if i < num_val:
X_val['input_ids'].append(inputs)
y_val.append(label)
else:
X_train['input_ids'].append(inputs)
y_train.append(label)
else:
X_test['input_ids'].append(inputs)
y_test.append(label)
if mode == 'train':
label2id = {label: i for i, label in enumerate(np.unique(y_train))}
id2label = {i: label for label, i in label2id.items()}
y_train = torch.tensor([label2id[label] for label in y_train], dtype=torch.long)
y_val = torch.tensor([label2id[label] for label in y_val], dtype=torch.long)
return X_train, y_train, X_val, y_val, label2id, id2label
else:
y_test = torch.tensor(y_test, dtype=torch.long)
return X_test, y_test
X_train, y_train, X_val, y_val, label2id, id2label = read_data(config, token2id, mode='train')
X_test, y_test = read_data(config, token2id, mode='test')
将转换好id的数据集,通过创建/继承 Dataset类提供数据集的封装, 再使用 Dataloader 实现数据并行加载,创建/继承 Dataset 必须实现python风格函数__len__()方法 返回整个数据集的长度,以及__getitem__(self, index)函数从而支持数据集索引
from torch.utils.data import Dataset
class TNEWSDataset(Dataset):
def __init__(self, X, y):
self.x = X
self.y = y
def __getitem__(self, idx):
return {
'input_ids': self.x['input_ids'][idx],
'label': self.y[idx]
}
def __len__(self):
return self.y.size(0)
pytorch Dataset类的文档解释,可见一下链接:
torch.utils.data — PyTorch 1.7.1 documentation
2.3 collete_fn将数据集转换为tensor
在收集Dataset的example后,需要将所有的句子id统一成相同长度的tensor并进行合并,此处将每句话的长度统一成了句子集合中长度最长的句子长度。pad操作隐含在torch.zeros的初始化操作中了。
def my_collate_fn(examples):
input_ids_list =[]
labels = []
for example in examples:
input_ids_list.append(example['input_ids'])
labels.append(example['label'])
# 1.找到 input_ids_list 中最长的句子
max_length = max(len(input_ids) for input_ids in input_ids_list)
# 2. 定义一个Tensor
input_ids_tensor = torch.zeros((len(labels), max_length), dtype=torch.long)
for i, input_ids in enumerate(input_ids_list):
# 3.得到当前句子长度
seq_len = len(input_ids)
input_ids_tensor[i, :seq_len] = torch.tensor(input_ids, dtype=torch.long)
return {
'input_ids': input_ids_tensor,
'label': torch.tensor(labels, dtype=torch.long)
}
2.4 使用dataloader读入数据
数据集映射为id、数据集类的封装、数据集转tensor合并以及DataLoader并行加载这些操作都封装在一起。
from torch.utils.data import DataLoader
def build_dataloader(config, vocab):
X_train, y_train, X_val, y_val, label2id, id2label = read_data(config, token2id, mode='train')
X_test, y_test = read_data(config, token2id, mode='test')
train_dataset = TNEWSDataset(X_train, y_train)
val_dataset = TNEWSDataset(X_val, y_val)
test_dataset = TNEWSDataset(X_test, y_test)
train_dataloader = DataLoader(dataset=train_dataset, batch_size=config['batch_size'], num_workers=4, shuffle=True, collate_fn=my_collate_fn)
val_dataloader = DataLoader(dataset=val_dataset, batch_size=config['batch_size'], num_workers=4, shuffle=False, collate_fn=my_collate_fn)
test_dataloader = DataLoader(dataset=test_dataset, batch_size=config['batch_size'], num_workers=4, shuffle=False, collate_fn=my_collate_fn)
return id2label, train_dataloader, val_dataloader, test_dataloader
id2label, train_dataloader, val_dataloader, test_dataloader = build_dataloader(config, vocab)
pytorch DataLoader类的文档解释,可见一下链接:
torch.utils.data — PyTorch 1.7.1 documentation